mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #962 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -23,6 +23,14 @@ export const StaffReference = () => applyDecorators(UseGuards(JwtGuard));
|
||||
|
||||
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view);
|
||||
|
||||
/**
|
||||
* The document-review countdown in the backoffice header. Its own permission so
|
||||
* it can be granted to exactly the position types that decide operation
|
||||
* requests, instead of every holder of bookings:view.
|
||||
*/
|
||||
export const BookingDocReviewAlert = () =>
|
||||
BookingStaff(FREIGHT_PERMS.bookings.docReviewAlert);
|
||||
|
||||
export const TrainSchedulingView = () =>
|
||||
BookingStaff(FREIGHT_PERMS.trainScheduling.view);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
assertCanApproveContractStep,
|
||||
canEditContractStep,
|
||||
} from './freight-permission.util';
|
||||
import { FREIGHT_PERMS } from '../seed/freight-permissions.registry';
|
||||
|
||||
const userWith = (...keys: string[]) => ({
|
||||
permissions: keys.map((key) => ({ key })),
|
||||
});
|
||||
|
||||
describe('hazardous contract approval steps', () => {
|
||||
it('rejects an approver who only holds ordinary contract-approve permissions', () => {
|
||||
// The blanket "any contract approve permission" fallback must NOT reach
|
||||
// dangerous goods — that is the whole point of the dedicated desks.
|
||||
const lineStaff = userWith(FREIGHT_PERMS.contracts.approveLineStaff);
|
||||
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE'),
|
||||
).toThrow(ForbiddenException);
|
||||
expect(canEditContractStep(lineStaff, 'HAZARDOUS_APPROVAL_ONE')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts only the matching hazardous permission', () => {
|
||||
const first = userWith(FREIGHT_PERMS.contracts.hazardousApprovalOne);
|
||||
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_ONE'),
|
||||
).not.toThrow();
|
||||
// Holding step one does not confer step two.
|
||||
expect(() =>
|
||||
assertCanApproveContractStep(first, 'HAZARDOUS_APPROVAL_TWO'),
|
||||
).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('does not let a hazardous approver stand in for the commercial chain', () => {
|
||||
const hazardOnly = userWith(
|
||||
FREIGHT_PERMS.contracts.hazardousApprovalOne,
|
||||
FREIGHT_PERMS.contracts.hazardousApprovalTwo,
|
||||
);
|
||||
|
||||
expect(() => assertCanApproveContractStep(hazardOnly, 'CEO')).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
CEO: FREIGHT_PERMS.bookings.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* Approval-chain roles synthesized for hazardous contracts (see
|
||||
* `instantiateApprovalSteps`). Unlike the legacy roles below they are NOT
|
||||
* position types — they authorize purely on their own dedicated permission, and
|
||||
* they deliberately opt out of the blanket "holds any contract-approve
|
||||
* permission" fallback so a normal approver cannot sign off dangerous goods.
|
||||
*/
|
||||
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
|
||||
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
|
||||
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
|
||||
};
|
||||
|
||||
/** The two hazardous steps, in the order they are prepended to the chain. */
|
||||
export const HAZARDOUS_APPROVAL_ROLES = [
|
||||
'HAZARDOUS_APPROVAL_ONE',
|
||||
'HAZARDOUS_APPROVAL_TWO',
|
||||
] as const;
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
|
||||
): void {
|
||||
if (isFreightApprovalAdmin(user)) return;
|
||||
|
||||
// Hazardous steps are permission-only and strict — no legacy alias, no
|
||||
// blanket approve fallback.
|
||||
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||
if (hazardousPermission) {
|
||||
if (hasFreightPermission(user, hazardousPermission)) return;
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${hazardousPermission}`,
|
||||
);
|
||||
}
|
||||
|
||||
const positionTypes = collectPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return;
|
||||
|
||||
@@ -219,6 +247,11 @@ export function canEditContractStep(
|
||||
): boolean {
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||
if (hazardousPermission) {
|
||||
return hasFreightPermission(user, hazardousPermission);
|
||||
}
|
||||
|
||||
const positionTypes = collectPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { hazardClassLabel } from '@edr/types';
|
||||
|
||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||
import {
|
||||
@@ -30,6 +31,8 @@ export interface ContractDocumentSignatureView {
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
/** Company stamp/seal; rendered next to the signature when present. */
|
||||
stampImageUrl?: string | null;
|
||||
}
|
||||
|
||||
/** A single unit-rate row on the contract PDF — price per unit, NO total. */
|
||||
@@ -141,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
|
||||
|
||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
||||
// Signed before company stamps were required — the customer has to sign
|
||||
// again to attach one, otherwise EDR can never counter-sign the contract.
|
||||
const customerStampMissing = signatures.some(
|
||||
(s) => s.role === 'CUSTOMER' && !s.stampImageUrl,
|
||||
);
|
||||
const hasContractFile = Boolean(
|
||||
contract.files?.some((f) => f.code === 'contract'),
|
||||
);
|
||||
@@ -183,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
|
||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||
// view-model's narrower CUSTOMER|STAFF role union.
|
||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
||||
canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer,
|
||||
canSignCustomer:
|
||||
(contract.status === 'CONTRACT_READY' && !hasCustomer) ||
|
||||
(contract.status === 'SIGNED_CUSTOMER' && customerStampMissing),
|
||||
canSignStaff:
|
||||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||
hasContractDocument: hasContractFile,
|
||||
@@ -208,6 +218,7 @@ export class ContractDocumentViewModelBuilder {
|
||||
signerDisplayName: row.signerDisplayName,
|
||||
signedAt: this.formatDate(row.signedAt),
|
||||
signatureImageUrl: row.signatureFile?.url ?? null,
|
||||
stampImageUrl: row.stampFile?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -292,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
|
||||
cargoDescription: this.valueOrDash(cargoName),
|
||||
totalWeightVgm: '—',
|
||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
||||
hazardousLabel: contract.isHazardous ? 'Yes' : 'No',
|
||||
// A hazardous contract names the declared class + UN number on the
|
||||
// schedule — the flag alone is not a dangerous-goods declaration.
|
||||
hazardousLabel: contract.isHazardous
|
||||
? [
|
||||
hazardClassLabel(contract.hazardClass) ?? 'Yes',
|
||||
contract.unNumber ? `UN ${contract.unNumber}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
: 'No',
|
||||
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||
};
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized EDR representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{#if stampImageUrl}}
|
||||
<div class="sig-stamp">
|
||||
<span class="sig-stamp-label">Company stamp</span>
|
||||
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Service provider stamp" /></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
@@ -32,6 +38,12 @@
|
||||
<p class="sig-line"><strong>Name:</strong> {{signerDisplayName}}</p>
|
||||
<p class="sig-meta"><strong>Role:</strong> Authorized client representative</p>
|
||||
<p class="sig-meta"><strong>Date:</strong> {{signedAt}}</p>
|
||||
{{#if stampImageUrl}}
|
||||
<div class="sig-stamp">
|
||||
<span class="sig-stamp-label">Company stamp</span>
|
||||
<div class="sig-stamp-box"><img src="{{stampImageUrl}}" alt="Client stamp" /></div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
|
||||
@@ -372,25 +372,30 @@
|
||||
font-size: 9pt;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Witnesses ────────────────────────────────────────────────────────── */
|
||||
.witnesses { margin-top: 20px; }
|
||||
.witness-table {
|
||||
font-size: 9.5pt;
|
||||
margin-top: 6px;
|
||||
.sig-stamp {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.witness-table th,
|
||||
.witness-table td {
|
||||
border-bottom: 1px solid #c9e4d9;
|
||||
padding: 9px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
.witness-table th {
|
||||
.sig-stamp-label {
|
||||
color: #0e5b45;
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 8.5pt;
|
||||
font-size: 7.5pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4pt;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sig-stamp-box {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: 30mm;
|
||||
justify-content: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.sig-stamp-box img {
|
||||
display: block;
|
||||
max-height: 30mm;
|
||||
max-width: 45mm;
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body { background: #fff; }
|
||||
|
||||
@@ -147,19 +147,6 @@
|
||||
authorized to sign and execute this Contract Agreement.
|
||||
</p>
|
||||
{{> signatures_block}}
|
||||
|
||||
<div class="witnesses">
|
||||
<p class="sig-title">Witnesses</p>
|
||||
<table class="witness-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Name</th><th>Signature</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1.</td><td></td><td></td><td></td></tr>
|
||||
<tr><td>2.</td><td></td><td></td><td></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Company stamp (seal) attached alongside the drawn signature, for both the
|
||||
* client and the EDR side. Stored the same way the signature image is: a
|
||||
* FileRecord on the contract (`resource: 'contracts'`, `code: 'stamp_<role>'`)
|
||||
* referenced from the signature row.
|
||||
*
|
||||
* Nullable — existing signature rows predate the stamp requirement. The
|
||||
* "both stamps recorded" gate lives in ContractTransitionService.counterSign,
|
||||
* not in a NOT NULL constraint, so historical rows stay readable.
|
||||
*/
|
||||
export class AddContractSignatureStamp2910000000000 implements MigrationInterface {
|
||||
name = 'AddContractSignatureStamp2910000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_signatures ADD COLUMN IF NOT EXISTS stamp_file_id uuid;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contract_signatures DROP COLUMN IF EXISTS stamp_file_id;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Hazardous contracts now declare WHAT the dangerous good is, not just that it
|
||||
* exists: the UN/ADR class (CLASS_1..CLASS_9) and the shipment's UN number. Both
|
||||
* are captured in the portal alongside the hazard documents and reviewed by the
|
||||
* two hazardous approval desks.
|
||||
*
|
||||
* Nullable — non-hazardous contracts leave both null, and contracts created
|
||||
* before this change have no declaration to backfill.
|
||||
*/
|
||||
export class AddContractHazardDeclaration2920000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddContractHazardDeclaration2920000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS hazard_class varchar(16);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS un_number varchar(16);`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS un_number;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS hazard_class;`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { BookingLifecycleNotifierService } from './booking-lifecycle-notifier.service';
|
||||
import type { Booking } from './entities/booking.entity';
|
||||
|
||||
/**
|
||||
* Who hears "Operations wants changes" depends on who owns the booking. A
|
||||
* customs (Path B) booking is created BY GL Ethiopia on the customer's behalf —
|
||||
* the customer can neither edit nor resubmit it, so the note has to reach the GL
|
||||
* who made it, not the portal.
|
||||
*/
|
||||
describe('BookingLifecycleNotifierService — operation changes requested', () => {
|
||||
const booking = (over: Partial<Booking> = {}): Booking =>
|
||||
({
|
||||
id: 'b-1',
|
||||
reference: 'BKG-0001',
|
||||
companyId: 'co-1',
|
||||
contractId: 'ctr-1',
|
||||
createdByRole: 'CUSTOMER',
|
||||
company: { email: 'customer@example.com' },
|
||||
...over,
|
||||
}) as Booking;
|
||||
|
||||
let notifications: { directSend: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: BookingLifecycleNotifierService;
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
beforeEach(() => {
|
||||
notifications = { directSend: jest.fn().mockResolvedValue(undefined) };
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new BookingLifecycleNotifierService(
|
||||
notifications as never,
|
||||
inbox as never,
|
||||
{ query: jest.fn().mockResolvedValue([{ phone: '+251900000000' }]) } as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends a GL-created booking back to the GL who created it, not the customer', async () => {
|
||||
service.operationChangesRequested(
|
||||
booking({ createdByRole: 'GL_ET', createdByUserId: 'gl-user-1' }),
|
||||
'Cargo weight does not match the declaration',
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ userIds: ['gl-user-1'] });
|
||||
expect(sent.audience).toBe('BACKOFFICE');
|
||||
expect(sent.body).toContain('Cargo weight does not match the declaration');
|
||||
// Deep-links the clearance page GL works from, not the portal booking.
|
||||
expect(sent.link).toBe('/dashboard/contracts/clearance/ctr-1');
|
||||
// The customer is not told to fix something they cannot touch.
|
||||
expect(notifications.directSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still tells the customer when the booking is their own', async () => {
|
||||
service.operationChangesRequested(booking(), 'Please attach the packing list');
|
||||
await flush();
|
||||
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ companyId: 'co-1' });
|
||||
expect(sent.audience).toBe('PORTAL');
|
||||
expect(sent.link).toBe('/bookings/b-1');
|
||||
expect(notifications.directSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the customer when the GL creator is unknown (legacy rows)', async () => {
|
||||
service.operationChangesRequested(
|
||||
booking({ createdByRole: 'GL_ET', createdByUserId: null }),
|
||||
'Fix the declaration',
|
||||
);
|
||||
await flush();
|
||||
|
||||
expect(inbox.notify.mock.calls[0][0].recipients).toEqual({ companyId: 'co-1' });
|
||||
});
|
||||
});
|
||||
@@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Operations returned the operation request for changes. */
|
||||
/**
|
||||
* Operations returned the operation request for changes.
|
||||
*
|
||||
* A customs (Path B) booking was created BY GL Ethiopia on the customer's
|
||||
* behalf — the customer cannot edit or resubmit it, so telling them to "update
|
||||
* from the portal" is a dead end. Those go to the GL who created it, linking
|
||||
* the contract clearance page they work from. Everything else (customer-made
|
||||
* bookings) keeps the portal message.
|
||||
*/
|
||||
operationChangesRequested(b: Booking, note: string): void {
|
||||
if (b.createdByRole === 'GL_ET' && b.createdByUserId) {
|
||||
const msg =
|
||||
`Operations returned booking ${b.reference} for changes: ${note}. ` +
|
||||
`Address it on the contract clearance page and resubmit to Operations.`;
|
||||
this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`);
|
||||
void this.inbox.notify({
|
||||
recipients: { userIds: [b.createdByUserId] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: `Booking ${b.reference} needs changes`,
|
||||
body: msg,
|
||||
link: b.contractId
|
||||
? `/dashboard/contracts/clearance/${b.contractId}`
|
||||
: `/dashboard/bookings/${b.id}/clearance`,
|
||||
data: { bookingId: b.id, reference: b.reference, note },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const msg =
|
||||
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
||||
`Please update and resubmit from the portal.`;
|
||||
|
||||
@@ -473,3 +473,124 @@ describe('BookingPricingService — customs clearance fee billed on the booking
|
||||
expect(result.lineItems.some((l) => l.code.startsWith('CUSTOMS_CLEARANCE'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Bulk freight bills in the commodity's own unit: tonnage for a weighed
|
||||
* commodity (PER_TON), item count for a counted one (PER_ITEM). Both read the
|
||||
* booking's cargo amount; PER_WAGON bills the wagons the cargo occupies.
|
||||
*/
|
||||
describe('BookingPricingService — bulk base freight units', () => {
|
||||
const DJ = 'yard-dj-bulk';
|
||||
const DIRE_B = 'yard-dire-bulk';
|
||||
|
||||
const bulkRate = (overrides: Partial<Rate> = {}): Rate =>
|
||||
({
|
||||
id: 'rate-bulk',
|
||||
rateType: 'BULK_IMPORT',
|
||||
appliesTo: 'BULK',
|
||||
trigger: 'ALWAYS',
|
||||
currency: 'USD',
|
||||
rateValue: 200,
|
||||
rateUnit: 'PER_ITEM',
|
||||
status: 'LIVE',
|
||||
containerTypeId: null,
|
||||
cargoTypeId: null,
|
||||
tradeDirection: 'IMPORT',
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE_B,
|
||||
...overrides,
|
||||
}) as Rate;
|
||||
|
||||
const makeService = (liveRates: Rate[], wagonCapacity?: number) =>
|
||||
new BookingPricingService(
|
||||
{
|
||||
calculateWagonCount: jest.fn().mockResolvedValue(0),
|
||||
findContractRateSnapshots: jest.fn().mockResolvedValue([]),
|
||||
} as never,
|
||||
{
|
||||
evaluate: jest.fn().mockResolvedValue({
|
||||
priorityScore: 0,
|
||||
appliedModifiers: [],
|
||||
containerWeightResults: [],
|
||||
warnings: [],
|
||||
hardBlocked: [],
|
||||
requiresDirectorApproval: false,
|
||||
}),
|
||||
} as never,
|
||||
{ findById: jest.fn() } as never,
|
||||
{ findLiveRates: jest.fn().mockResolvedValue(liveRates) } as never,
|
||||
{ getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE) } as never,
|
||||
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
|
||||
{
|
||||
findById: jest.fn().mockResolvedValue({
|
||||
wagonTypes: wagonCapacity !== undefined ? [{ capacityTons: wagonCapacity }] : [],
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
|
||||
// 12 machines, not 12 tonnes — a PER_ITEM commodity records its count here.
|
||||
const booking = (overrides: Record<string, unknown> = {}) =>
|
||||
({
|
||||
id: 'b-bulk',
|
||||
freightType: 'BULK',
|
||||
tradeDirection: 'IMPORT',
|
||||
paymentCurrency: 'USD',
|
||||
cargoTypeId: 'cargo-machinery',
|
||||
cargoTotalWeightVgm: 12,
|
||||
originYardId: DJ,
|
||||
destinationYardId: DIRE_B,
|
||||
bookingContainers: [],
|
||||
...overrides,
|
||||
}) as unknown as Booking;
|
||||
|
||||
it('bills a PER_ITEM rate on the item count', async () => {
|
||||
const result = await makeService([bulkRate()]).computePriceForBooking(booking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_ITEM');
|
||||
expect(line!.quantity).toBe(12);
|
||||
expect(line!.amount).toBe(2400);
|
||||
});
|
||||
|
||||
it('bills a PER_TON rate on the tonnage', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ rateUnit: 'PER_TON', rateValue: 35 }),
|
||||
]).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_TON');
|
||||
expect(line!.amount).toBe(35 * 120);
|
||||
});
|
||||
|
||||
it('bills a PER_WAGON rate on the wagons the cargo occupies, not zero', async () => {
|
||||
const result = await makeService(
|
||||
[bulkRate({ rateUnit: 'PER_WAGON', rateValue: 500 })],
|
||||
60,
|
||||
).computePriceForBooking(booking({ cargoTotalWeightVgm: 120 }));
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_WAGON');
|
||||
expect(line!.quantity).toBe(2); // 120 t ÷ 60 t per wagon
|
||||
expect(line!.amount).toBe(1000);
|
||||
});
|
||||
|
||||
it('prices off the rate scoped to the booking commodity, not another one', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat', rateUnit: 'PER_TON', rateValue: 35 }),
|
||||
bulkRate({ id: 'rate-machinery', cargoTypeId: 'cargo-machinery', rateValue: 200 }),
|
||||
]).computePriceForBooking(booking());
|
||||
|
||||
const line = result.lineItems.find((l) => l.code === 'BULK_IMPORT');
|
||||
expect(line!.unit).toBe('PER_ITEM');
|
||||
expect(line!.amount).toBe(2400);
|
||||
});
|
||||
|
||||
it('hard-blocks when the leg only carries another commodity’s rate', async () => {
|
||||
const result = await makeService([
|
||||
bulkRate({ id: 'rate-wheat', cargoTypeId: 'cargo-wheat' }),
|
||||
]).computePriceForBooking(booking());
|
||||
|
||||
expect(result.lineItems.some((l) => l.code === 'BULK_IMPORT')).toBe(false);
|
||||
expect(result.hardBlocked.some((m) => m.includes('rate is configured'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from '../rule-engine/entities/rate-unit.util';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import {
|
||||
@@ -529,7 +530,13 @@ export class BookingPricingService {
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const warnings: string[] = [];
|
||||
const blocked: string[] = [];
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
// Bulk bookings carry no container lines, so the container-based wagon
|
||||
// aggregate is 0 for them — a PER_WAGON bulk rate would bill nothing. Use
|
||||
// the tonnage-derived estimate instead (the eval input already carries it
|
||||
// for saved bookings; a preview derives it here).
|
||||
const wagonCount = isBulk
|
||||
? Number(evalInput.bulkWagons ?? 0) || (await this.bulkWagonCount(booking)) || 0
|
||||
: await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(
|
||||
@@ -600,7 +607,10 @@ export class BookingPricingService {
|
||||
// container type above or stay unpriced with a warning — falling back to
|
||||
// a corridor rate of a DIFFERENT container type billed once (qty 1) is
|
||||
// how a 38-container booking was invoiced 40 USD instead of 1900.
|
||||
const fallback = liveRates.find(
|
||||
// Within the leg, the rate scoped to the booking's own commodity wins over
|
||||
// the commodity-wide catch-all — a per-item machinery rate must never
|
||||
// price a per-ton wheat booking (or the reverse).
|
||||
const onLeg = liveRates.filter(
|
||||
(r) =>
|
||||
r.rateType === rateType &&
|
||||
r.currency === 'USD' &&
|
||||
@@ -608,11 +618,17 @@ export class BookingPricingService {
|
||||
r.originYardId === booking.originYardId &&
|
||||
r.destinationYardId === booking.destinationYardId,
|
||||
);
|
||||
const fallback =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find((r) => r.cargoTypeId === booking.cargoTypeId)
|
||||
: undefined) ?? onLeg.find((r) => !r.cargoTypeId);
|
||||
if (fallback) {
|
||||
usedRatesMap.set(fallback.id, fallback);
|
||||
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
// Bulk quantity is stored in the commodity's own unit — tonnes for a
|
||||
// PER_TON commodity, item count for a PER_ITEM one.
|
||||
const bulkQuantity = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const quantity =
|
||||
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
|
||||
isBulk && isBulkQuantityUnit(fallback.rateUnit) ? Math.max(bulkQuantity, 0) : 1;
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
@@ -719,6 +735,7 @@ export class BookingPricingService {
|
||||
quantity = containerCount;
|
||||
break;
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
quantity = bulkTons;
|
||||
break;
|
||||
case 'FLAT':
|
||||
@@ -804,6 +821,7 @@ export class BookingPricingService {
|
||||
return 1;
|
||||
case 'PER_CONTAINER':
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
default:
|
||||
return quantity;
|
||||
}
|
||||
@@ -860,6 +878,7 @@ export class BookingPricingService {
|
||||
case 'PER_WAGON':
|
||||
return unitValue * wagonCount;
|
||||
case 'PER_TON':
|
||||
case 'PER_ITEM':
|
||||
return unitValue * quantity;
|
||||
case 'FLAT':
|
||||
return unitValue;
|
||||
@@ -1044,7 +1063,7 @@ export class BookingPricingService {
|
||||
const unit = frozen ? this.rateUnitFromSnapshot(frozen.unitOfMeasure) : live!.rateUnit;
|
||||
const unitAmount = frozen ? Number(frozen.unitPrice) : convert(Number(live!.rateValue));
|
||||
let billedQty = 1;
|
||||
if (unit === 'PER_TON') {
|
||||
if (isBulkQuantityUnit(unit)) {
|
||||
billedQty = Math.max(0, Number(booking.cargoTotalWeightVgm ?? 0));
|
||||
} else if (unit === 'PER_WAGON') {
|
||||
const wagons = await this.bulkWagonCount(booking);
|
||||
@@ -1081,6 +1100,8 @@ export class BookingPricingService {
|
||||
return 'PER_WAGON';
|
||||
case 'per_ton':
|
||||
return 'PER_TON';
|
||||
case 'per_item':
|
||||
return 'PER_ITEM';
|
||||
case 'per_container':
|
||||
return 'PER_CONTAINER';
|
||||
default:
|
||||
|
||||
@@ -33,41 +33,86 @@ import {
|
||||
BookingReferenceYardDto,
|
||||
} from "./dto/booking-reference-data.dto";
|
||||
|
||||
/**
|
||||
* Reference cargo tree: top-level groups, each carrying its selectable
|
||||
* commodities.
|
||||
*
|
||||
* `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but
|
||||
* only a LEAF is a real commodity — an intermediate node is a container for
|
||||
* finer types, and booking against it would be ambiguous. So each group's
|
||||
* `children` are all of its leaf descendants, flattened, whatever the depth.
|
||||
* Deep leaves carry their path below the group ("Steel Billet → S1") so a
|
||||
* generically-named leaf still reads unambiguously in a dropdown.
|
||||
*
|
||||
* A group with no active descendants is its own leaf and is emitted as its
|
||||
* single child — otherwise it is selectable as a group but offers no commodity,
|
||||
* which dead-ends every form that requires one.
|
||||
*/
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
): BookingReferenceCargoTypeGroupDto[] {
|
||||
const active = rows.filter((r) => r.isActive);
|
||||
const parents = active
|
||||
.filter((r) => !r.parentGroupId)
|
||||
.sort(
|
||||
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
);
|
||||
|
||||
const byOrder = (a: CargoType, b: CargoType) =>
|
||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
|
||||
|
||||
const childrenOf = new Map<string, CargoType[]>();
|
||||
for (const row of active) {
|
||||
if (!row.parentGroupId) continue;
|
||||
const siblings = childrenOf.get(row.parentGroupId) ?? [];
|
||||
siblings.push(row);
|
||||
childrenOf.set(row.parentGroupId, siblings);
|
||||
}
|
||||
for (const siblings of childrenOf.values()) siblings.sort(byOrder);
|
||||
|
||||
const parents = active.filter((r) => !r.parentGroupId).sort(byOrder);
|
||||
|
||||
/** Depth-first leaf walk; `trail` is the path below the group. */
|
||||
const collectLeaves = (
|
||||
node: CargoType,
|
||||
trail: string[],
|
||||
seen: Set<string>,
|
||||
): BookingReferenceCargoTypeChildDto[] => {
|
||||
// Admin-entered parent pointers could in principle cycle — never loop.
|
||||
if (seen.has(node.id)) return [];
|
||||
seen.add(node.id);
|
||||
|
||||
const kids = childrenOf.get(node.id) ?? [];
|
||||
if (kids.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: node.id,
|
||||
name: [...trail, node.cargoTypeName].join(" → "),
|
||||
code: node.code,
|
||||
unit_of_measure: node.unitOfMeasure ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const nextTrail = [...trail, node.cargoTypeName];
|
||||
return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen));
|
||||
};
|
||||
|
||||
return parents.map((parent) => {
|
||||
const children = active
|
||||
.filter((r) => r.parentGroupId === parent.id)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
||||
)
|
||||
.map(
|
||||
(child): BookingReferenceCargoTypeChildDto => ({
|
||||
id: child.id,
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
const kids = childrenOf.get(parent.id) ?? [];
|
||||
const children =
|
||||
kids.length === 0
|
||||
? // The group itself is the commodity.
|
||||
[
|
||||
{
|
||||
id: parent.id,
|
||||
name: parent.cargoTypeName,
|
||||
code: parent.code,
|
||||
unit_of_measure: parent.unitOfMeasure ?? null,
|
||||
},
|
||||
]
|
||||
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
|
||||
|
||||
const group: BookingReferenceCargoTypeGroupDto = {
|
||||
return {
|
||||
id: parent.id,
|
||||
name: parent.cargoTypeName,
|
||||
code: parent.code,
|
||||
children,
|
||||
};
|
||||
if (children.length > 0) {
|
||||
group.children = children;
|
||||
}
|
||||
return group;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { buildCargoTypeTree } from './booking-reference-data.service';
|
||||
import type { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
|
||||
const node = (
|
||||
id: string,
|
||||
name: string,
|
||||
parentGroupId: string | null,
|
||||
isActive = true,
|
||||
): CargoType =>
|
||||
({
|
||||
id,
|
||||
cargoTypeName: name,
|
||||
code: name.toUpperCase().replace(/\s+/g, '_'),
|
||||
parentGroupId,
|
||||
displayOrder: 0,
|
||||
isActive,
|
||||
unitOfMeasure: 'PER_TON',
|
||||
}) as unknown as CargoType;
|
||||
|
||||
describe('buildCargoTypeTree', () => {
|
||||
// Bulk ──┬─ Wheat (leaf, depth 2)
|
||||
// └─ Steel Billet ──┬─ S1 (leaf, depth 3)
|
||||
// └─ S2 ─ S2a (leaf, depth 4)
|
||||
const rows = [
|
||||
node('bulk', 'Bulk', null),
|
||||
node('wheat', 'Wheat', 'bulk'),
|
||||
node('steel', 'Steel Billet', 'bulk'),
|
||||
node('s1', 'S1', 'steel'),
|
||||
node('s2', 'S2', 'steel'),
|
||||
node('s2a', 'S2a', 's2'),
|
||||
node('general', 'General Cargo', null),
|
||||
];
|
||||
|
||||
it('offers only leaves as commodities, at any depth', () => {
|
||||
const [bulk] = buildCargoTypeTree(rows);
|
||||
|
||||
// Leaves stay grouped under their branch (siblings ordered by
|
||||
// displayOrder then code — STEEL_BILLET before WHEAT here).
|
||||
expect(bulk.children?.map((c) => c.id)).toEqual(['s1', 's2a', 'wheat']);
|
||||
// "Steel Billet" is a container for finer types, never bookable itself.
|
||||
expect(bulk.children?.some((c) => c.id === 'steel')).toBe(false);
|
||||
});
|
||||
|
||||
it('labels deep leaves with their path below the group', () => {
|
||||
const [bulk] = buildCargoTypeTree(rows);
|
||||
const byId = new Map(bulk.children?.map((c) => [c.id, c.name]));
|
||||
|
||||
expect(byId.get('wheat')).toBe('Wheat');
|
||||
expect(byId.get('s1')).toBe('Steel Billet → S1');
|
||||
expect(byId.get('s2a')).toBe('Steel Billet → S2 → S2a');
|
||||
});
|
||||
|
||||
it('emits a childless group as its own commodity', () => {
|
||||
const general = buildCargoTypeTree(rows).find((g) => g.id === 'general');
|
||||
|
||||
expect(general?.children).toEqual([
|
||||
expect.objectContaining({ id: 'general', name: 'General Cargo' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips inactive nodes and their descendants', () => {
|
||||
const withRetired = [
|
||||
...rows,
|
||||
node('retired', 'Retired', 'bulk', false),
|
||||
node('retiredKid', 'Retired Kid', 'retired', false),
|
||||
];
|
||||
const [bulk] = buildCargoTypeTree(withRetired);
|
||||
|
||||
expect(bulk.children?.map((c) => c.id)).not.toContain('retired');
|
||||
expect(bulk.children?.map((c) => c.id)).not.toContain('retiredKid');
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,14 @@ export interface ContractClearanceView {
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
/**
|
||||
* Operations' latest "needs changes" note on that booking. GL created the
|
||||
* booking, so GL is the one who has to act on it — surfaced here because the
|
||||
* clearance page is where GL works, not the portal.
|
||||
*/
|
||||
linkedBookingReviewNote?: string | null;
|
||||
/** Shipment day the booking currently holds — the default when GL resubmits. */
|
||||
linkedBookingScheduledDate?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -301,11 +309,24 @@ export class ContractClearanceService {
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingReference: string | null = null;
|
||||
let linkedBookingStatus: string | null = null;
|
||||
let linkedBookingReviewNote: string | null = null;
|
||||
let linkedBookingScheduledDate: string | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
linkedBookingStatus = booking.status ?? null;
|
||||
linkedBookingScheduledDate = booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString()
|
||||
: null;
|
||||
// Newest changes-requested note (reviewNotes ride along on findById).
|
||||
linkedBookingReviewNote =
|
||||
[...(booking.reviewNotes ?? [])]
|
||||
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0]?.note ?? null;
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
@@ -349,6 +370,8 @@ export class ContractClearanceService {
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
linkedBookingReviewNote,
|
||||
linkedBookingScheduledDate,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ContractExpiryService } from './contract-expiry.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* The reminder must warn each customer once, ten days out, and must never let a
|
||||
* notification failure escape into the scheduler (that would also take out the
|
||||
* expiry sweep sharing this service).
|
||||
*/
|
||||
describe('ContractExpiryService — expiry reminder', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-2026-00042',
|
||||
companyId: 'co-1',
|
||||
contractValidUntil: new Date('2026-08-10T00:00:00.000Z'),
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: ContractExpiryService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
expireLapsedContracts: jest.fn().mockResolvedValue(0),
|
||||
findExpiringInDays: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ContractExpiryService(repo as never, inbox as never);
|
||||
});
|
||||
|
||||
it('asks for the contracts lapsing ten days out', async () => {
|
||||
await service.remindExpiringContracts();
|
||||
expect(repo.findExpiringInDays).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('notifies the owning company once, deep-linking the contract list', async () => {
|
||||
repo.findExpiringInDays.mockResolvedValue([contract()]);
|
||||
|
||||
await service.remindExpiringContracts();
|
||||
|
||||
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ companyId: 'co-1' });
|
||||
expect(sent.title).toContain('CTR-2026-00042');
|
||||
expect(sent.title).toContain('10 days');
|
||||
expect(sent.link).toBe('/contracts');
|
||||
expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' });
|
||||
});
|
||||
|
||||
it('skips a contract with no owning company (nobody to notify)', async () => {
|
||||
repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]);
|
||||
await service.remindExpiringContracts();
|
||||
expect(inbox.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a notification failure instead of throwing into the scheduler', async () => {
|
||||
repo.findExpiringInDays.mockResolvedValue([contract()]);
|
||||
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
||||
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
|
||||
/**
|
||||
* How many days before a contract lapses the customer is reminded. Mirrored by
|
||||
* the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows
|
||||
* the same countdown on the row.
|
||||
*/
|
||||
const EXPIRY_NOTICE_DAYS = 10;
|
||||
|
||||
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
|
||||
@Injectable()
|
||||
export class ContractExpiryService {
|
||||
private readonly logger = new Logger(ContractExpiryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Warn every customer whose contract lapses in ~10 days, once. The repository
|
||||
* window is a rolling 24h slice, so a contract is picked up by exactly one
|
||||
* daily run — no reminded-flag column needed.
|
||||
*
|
||||
* ponytail: a missed run (API down over the slice) skips that contract's
|
||||
* reminder; the portal list still shows its countdown for the whole window.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' })
|
||||
async remindExpiringContracts(): Promise<void> {
|
||||
try {
|
||||
const expiring =
|
||||
await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS);
|
||||
let notified = 0;
|
||||
for (const contract of expiring) {
|
||||
if (!contract.companyId || !contract.contractValidUntil) continue;
|
||||
const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB');
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: contract.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.CONTRACT_STATUS,
|
||||
title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`,
|
||||
body:
|
||||
`Your contract ${contract.reference} is valid until ${endsOn}. ` +
|
||||
'After that date it stops accepting new bookings — contact EDR if ' +
|
||||
'you need it renewed.',
|
||||
link: '/contracts',
|
||||
data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' },
|
||||
});
|
||||
notified += 1;
|
||||
}
|
||||
this.logger.log(
|
||||
`Contract expiry reminder: ${notified} customer(s) warned of a contract ` +
|
||||
`lapsing in ${EXPIRY_NOTICE_DAYS} days`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Never throws into the scheduler — a failed reminder must not stop the
|
||||
// expiry sweep from running.
|
||||
this.logger.error(
|
||||
`Contract expiry reminder failed: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
|
||||
async expireLapsedContracts(): Promise<void> {
|
||||
try {
|
||||
const affected = await this.contractsRepository.expireLapsedContracts();
|
||||
this.logger.log(`Contract expiry sweep: ${affected} contract(s) marked EXPIRED`);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Contract expiry sweep failed: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
try {
|
||||
await this.inbox.notify({
|
||||
recipients: { allBackoffice: true },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title: 'Contract expiry sweep failed',
|
||||
body: `The nightly job that expires lapsed contracts failed: ${(err as Error).message}. Contracts past their validity date may still show as active until this is fixed.`,
|
||||
data: { action: 'CONTRACT_EXPIRY_SWEEP_FAILED' },
|
||||
});
|
||||
} catch (notifyErr) {
|
||||
this.logger.error(
|
||||
`Contract expiry sweep failure alert also failed: ${(notifyErr as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ function toContractUnit(rateUnit: string): ContractUnitRateLineItem['unit'] {
|
||||
switch (rateUnit) {
|
||||
case 'PER_TON':
|
||||
return 'per_ton';
|
||||
case 'PER_ITEM':
|
||||
return 'per_item';
|
||||
case 'PER_KM':
|
||||
return 'per_km';
|
||||
case 'PER_WAGON':
|
||||
@@ -115,9 +117,19 @@ export class ContractPricingService {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const bulkRate =
|
||||
liveRates.find((r) => r.rateType === baseType && r.currency === 'USD') ?? null;
|
||||
const cargoScope = (contract.cargoScope ?? []).find((c) => c.cargoTypeId);
|
||||
// Freeze the rate for the contract's own commodity when one is configured
|
||||
// — a per-item machinery rate and a per-ton wheat rate live side by side.
|
||||
const bulkRates = liveRates.filter(
|
||||
(r) => r.rateType === baseType && r.currency === 'USD',
|
||||
);
|
||||
const bulkRate =
|
||||
(cargoScope?.cargoTypeId
|
||||
? bulkRates.find((r) => r.cargoTypeId === cargoScope.cargoTypeId)
|
||||
: undefined) ??
|
||||
bulkRates.find((r) => !r.cargoTypeId) ??
|
||||
bulkRates[0] ??
|
||||
null;
|
||||
if (bulkRate) {
|
||||
lineItems.push({
|
||||
code: 'BULK_FREIGHT',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
|
||||
/**
|
||||
* A stamp may be uploaded as JPEG/WebP while a drawn signature is always PNG.
|
||||
* The type must survive the round-trip: data URL in → stored object extension
|
||||
* → data URL out. Getting this wrong labels JPEG bytes as image/png in the
|
||||
* contract PDF and leaves the seal to browser content-sniffing.
|
||||
*/
|
||||
describe('ContractTransitionService signature/stamp asset typing', () => {
|
||||
const pngPixel =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==';
|
||||
const jpegPixel = `data:image/jpeg;base64,${Buffer.from('fake-jpeg').toString('base64')}`;
|
||||
|
||||
/** Minimal service instance — only filesService/minioService are exercised. */
|
||||
const build = () => {
|
||||
const uploaded: Array<{ code: string; mimetype: string; name: string }> = [];
|
||||
const filesService = {
|
||||
upsertByCode: jest.fn(({ code, file }) => {
|
||||
uploaded.push({ code, mimetype: file.mimetype, name: file.originalname });
|
||||
return Promise.resolve({ id: `file-${code}`, url: `https://minio/x/${file.originalname}` });
|
||||
}),
|
||||
};
|
||||
const minioService = {
|
||||
getObjectNameFromUrl: (url: string) => url.split('/').pop() ?? '',
|
||||
getFileStream: () => Promise.resolve(Readable.from(Buffer.from('bytes'))),
|
||||
};
|
||||
const service = Object.create(
|
||||
ContractTransitionService.prototype,
|
||||
) as ContractTransitionService;
|
||||
Object.assign(service, { filesService, minioService });
|
||||
return { service, uploaded };
|
||||
};
|
||||
|
||||
const contract = { id: 'c-1', reference: 'CTR-2026-00001' };
|
||||
|
||||
it('stores a drawn PNG signature as image/png', async () => {
|
||||
const { service, uploaded } = build();
|
||||
await (service as never as {
|
||||
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
|
||||
}).uploadSignatureAsset(contract, 'signature_customer', pngPixel);
|
||||
|
||||
expect(uploaded[0].mimetype).toBe('image/png');
|
||||
expect(uploaded[0].name).toBe('signature-customer-CTR-2026-00001.png');
|
||||
});
|
||||
|
||||
it('keeps an uploaded JPEG stamp as image/jpeg, not image/png', async () => {
|
||||
const { service, uploaded } = build();
|
||||
await (service as never as {
|
||||
uploadSignatureAsset: (c: unknown, code: string, b64: string) => Promise<unknown>;
|
||||
}).uploadSignatureAsset(contract, 'stamp_customer', jpegPixel);
|
||||
|
||||
expect(uploaded[0].mimetype).toBe('image/jpeg');
|
||||
expect(uploaded[0].name).toBe('stamp-customer-CTR-2026-00001.jpg');
|
||||
});
|
||||
|
||||
it('inlines a stored .jpg back as a data:image/jpeg URI', async () => {
|
||||
const { service } = build();
|
||||
const inline = (service as never as {
|
||||
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
|
||||
}).inlineImageUrl.bind(service);
|
||||
|
||||
await expect(inline('https://minio/x/stamp-customer-CTR.jpg')).resolves.toMatch(
|
||||
/^data:image\/jpeg;base64,/,
|
||||
);
|
||||
await expect(inline('https://minio/x/signature-customer-CTR.png')).resolves.toMatch(
|
||||
/^data:image\/png;base64,/,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through empty and already-inlined values untouched', async () => {
|
||||
const { service } = build();
|
||||
const inline = (service as never as {
|
||||
inlineImageUrl: (url?: string | null) => Promise<string | null | undefined>;
|
||||
}).inlineImageUrl.bind(service);
|
||||
|
||||
await expect(inline(null)).resolves.toBeNull();
|
||||
await expect(inline(pngPixel)).resolves.toBe(pngPixel);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
|
||||
/**
|
||||
* Signing is one-shot. The single exception: a contract signed before company
|
||||
* stamps were required must be re-signable so the customer can attach one —
|
||||
* otherwise counterSign's both-stamps gate strands it forever. These specs pin
|
||||
* that exception open and pin everything else shut.
|
||||
*/
|
||||
describe('customer re-sign to attach a missing stamp', () => {
|
||||
const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' };
|
||||
const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||
|
||||
const build = (contract: unknown, existingSignature: unknown) => {
|
||||
const applied: unknown[] = [];
|
||||
const service = Object.create(
|
||||
ContractTransitionService.prototype,
|
||||
) as ContractTransitionService;
|
||||
Object.assign(service, {
|
||||
contractsService: {
|
||||
findById: jest.fn().mockResolvedValue(contract),
|
||||
assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
contractsRepository: {
|
||||
findSignature: jest.fn().mockResolvedValue(existingSignature),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
otpService: {
|
||||
verifyOtpForAction: jest.fn().mockResolvedValue(undefined),
|
||||
sendOtp: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
notifier: { customerSignedToStaff: jest.fn() },
|
||||
resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }),
|
||||
applySignature: jest.fn((...args: unknown[]) => {
|
||||
applied.push(args);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
regenerateContractPdf: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
return { service, applied };
|
||||
};
|
||||
|
||||
const dto = {
|
||||
role: 'CUSTOMER' as const,
|
||||
signerDisplayName: 'C. Customer',
|
||||
signatureImageBase64: 'data:image/png;base64,AAAA',
|
||||
stampImageBase64: 'data:image/png;base64,BBBB',
|
||||
otp: '123456',
|
||||
};
|
||||
|
||||
it('lets a customer sign again when their signature has no stamp', async () => {
|
||||
const { service, applied } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: null,
|
||||
});
|
||||
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still refuses a second signature once a stamp is on file', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: 'file-1',
|
||||
});
|
||||
|
||||
// Stamped already → not the re-sign case, so the status guard rejects
|
||||
// SIGNED_CUSTOMER before the already-signed check is reached.
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a second signature on a still-ready contract', async () => {
|
||||
const { service } = build(contractReady, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: 'file-1',
|
||||
});
|
||||
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow(
|
||||
/already signed/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('signs normally when nothing is on file yet', async () => {
|
||||
const { service, applied } = build(contractReady, null);
|
||||
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends a signing OTP for the stamp re-sign', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
|
||||
).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('refuses a signing OTP once the contract is signed and stamped', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: 'file-1',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('requires the OTP on the re-sign path too', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
assertCanApproveContractStep,
|
||||
assertFreightPermission,
|
||||
canEditContractStep,
|
||||
HAZARDOUS_APPROVAL_ROLES,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
@@ -530,12 +531,26 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of chain) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
|
||||
// chain — if either refuses, the contract never reaches the approvers who
|
||||
// would price and sign it. Steps are renumbered sequentially so the prefix
|
||||
// and the configured chain form one ordered list.
|
||||
const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
|
||||
...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
|
||||
(requiredRole) => ({ requiredRole, blocksRole: null }),
|
||||
),
|
||||
...chain.map((rule) => ({
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const [index, role] of roles.entries()) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: index + 1,
|
||||
requiredRole: role.requiredRole,
|
||||
blocksRole: role.blocksRole,
|
||||
status: 'PENDING',
|
||||
});
|
||||
}
|
||||
@@ -928,23 +943,41 @@ export class ContractTransitionService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace MinIO signature URLs with inline data URIs so they render in the PDF. */
|
||||
/**
|
||||
* Replace MinIO signature/stamp URLs with inline data URIs so they render in
|
||||
* the PDF — Chromium cannot fetch the private bucket.
|
||||
*/
|
||||
private async inlineSignatureImages(
|
||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||
signatures: Array<{
|
||||
signatureImageUrl?: string | null;
|
||||
stampImageUrl?: string | null;
|
||||
}>,
|
||||
): Promise<void> {
|
||||
for (const sig of signatures) {
|
||||
if (!sig.signatureImageUrl) continue;
|
||||
try {
|
||||
if (sig.signatureImageUrl.startsWith('data:')) continue;
|
||||
const objectName = this.minioService.getObjectNameFromUrl(
|
||||
sig.signatureImageUrl,
|
||||
);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
/* keep original url */
|
||||
}
|
||||
sig.signatureImageUrl = await this.inlineImageUrl(sig.signatureImageUrl);
|
||||
sig.stampImageUrl = await this.inlineImageUrl(sig.stampImageUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/** MinIO URL → data URI. Returns the input unchanged if absent or on failure. */
|
||||
private async inlineImageUrl(
|
||||
url?: string | null,
|
||||
): Promise<string | null | undefined> {
|
||||
if (!url || url.startsWith('data:')) return url;
|
||||
try {
|
||||
const objectName = this.minioService.getObjectNameFromUrl(url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
const buffer = await this.streamToBuffer(stream);
|
||||
const extension = objectName.split('.').pop()?.toLowerCase();
|
||||
const mime =
|
||||
extension === 'jpg' || extension === 'jpeg'
|
||||
? 'image/jpeg'
|
||||
: extension === 'webp'
|
||||
? 'image/webp'
|
||||
: 'image/png';
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +992,46 @@ export class ContractTransitionService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* base64 (data URL or raw) → image FileRecord stored on the contract under
|
||||
* `code`. Drawn signatures are always PNG; an uploaded stamp may be JPEG or
|
||||
* WebP, so the type is read off the data-URL prefix rather than assumed —
|
||||
* the stored extension is what {@link inlineImageUrl} reads it back as.
|
||||
*/
|
||||
private async uploadSignatureAsset(
|
||||
contract: Contract,
|
||||
code: string,
|
||||
imageBase64: string,
|
||||
): Promise<FileRecord> {
|
||||
const mimetype =
|
||||
/^data:(image\/[a-z+]+);base64,/i.exec(imageBase64)?.[1]?.toLowerCase() ??
|
||||
'image/png';
|
||||
const extension = mimetype === 'image/jpeg' ? 'jpg' : mimetype.split('/')[1];
|
||||
const raw = imageBase64.includes(',')
|
||||
? imageBase64.split(',')[1]!
|
||||
: imageBase64;
|
||||
const buffer = Buffer.from(raw, 'base64');
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: code,
|
||||
originalname: `${code.replace(/_/g, '-')}-${contract.reference}.${extension}`,
|
||||
encoding: '7bit',
|
||||
mimetype,
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
return this.filesService.upsertByCode({
|
||||
resourceId: contract.id,
|
||||
resource: 'contracts',
|
||||
code,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
/** Apply a digital signature row (mirrors booking-contract.service). */
|
||||
private async applySignature(
|
||||
contract: Contract,
|
||||
@@ -985,29 +1058,28 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
const raw = imageBase64.includes(',')
|
||||
? imageBase64.split(',')[1]!
|
||||
: imageBase64;
|
||||
const buffer = Buffer.from(raw, 'base64');
|
||||
const sigFile: Express.Multer.File = {
|
||||
fieldname: `signature_${role.toLowerCase()}`,
|
||||
originalname: `signature-${role.toLowerCase()}-${contract.reference}.png`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'image/png',
|
||||
size: buffer.length,
|
||||
buffer,
|
||||
stream: Readable.from(buffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
// The company stamp is a separate image from the drawn signature. Both
|
||||
// parties to the contract (client + EDR) must seal it; DIRECTOR/CEO rows
|
||||
// are internal approval signatures, not party seals, so they stay exempt.
|
||||
const stampRequired = role === 'CUSTOMER' || role === 'STAFF';
|
||||
if (stampRequired && !dto.stampImageBase64) {
|
||||
throw new BadRequestException(
|
||||
'A company stamp is required to sign this contract.',
|
||||
);
|
||||
}
|
||||
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: contract.id,
|
||||
resource: 'contracts',
|
||||
code: `signature_${role.toLowerCase()}`,
|
||||
file: sigFile,
|
||||
});
|
||||
const fileRecord = await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`signature_${role.toLowerCase()}`,
|
||||
imageBase64,
|
||||
);
|
||||
const stampRecord = dto.stampImageBase64
|
||||
? await this.uploadSignatureAsset(
|
||||
contract,
|
||||
`stamp_${role.toLowerCase()}`,
|
||||
dto.stampImageBase64,
|
||||
)
|
||||
: null;
|
||||
|
||||
await this.contractsRepository.saveSignature({
|
||||
contractId: contract.id,
|
||||
@@ -1015,6 +1087,7 @@ export class ContractTransitionService {
|
||||
signerDisplayName,
|
||||
signedAt: new Date(),
|
||||
signatureFileId: fileRecord.id,
|
||||
stampFileId: stampRecord?.id ?? null,
|
||||
consentText: dto.consentText ?? null,
|
||||
});
|
||||
|
||||
@@ -1053,7 +1126,17 @@ export class ContractTransitionService {
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
// SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
|
||||
// {@link sign} permits — otherwise the code would be useless on arrival.
|
||||
const existing = await this.contractsRepository.findSignature(
|
||||
contractId,
|
||||
'CUSTOMER',
|
||||
);
|
||||
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
|
||||
assertContractStatus(
|
||||
contract,
|
||||
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
|
||||
);
|
||||
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
await this.otpService.sendOtp(signerContacts);
|
||||
@@ -1077,9 +1160,16 @@ export class ContractTransitionService {
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
// Signing is one-shot, with one exception: a contract signed before the
|
||||
// company stamp was required has to be sealed before EDR can counter-sign
|
||||
// it, so the customer may sign again purely to attach the missing stamp.
|
||||
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
|
||||
assertContractStatus(
|
||||
contract,
|
||||
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
|
||||
);
|
||||
if (existing && !addingMissingStamp) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
@@ -1127,6 +1217,19 @@ export class ContractTransitionService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
assertContractStatus(contract, ['SIGNED_CUSTOMER']);
|
||||
|
||||
// Both parties' stamps must be on file before the contract executes. The
|
||||
// EDR stamp is enforced by applySignature below; the customer's is checked
|
||||
// here so a contract signed before stamps existed can't slip through.
|
||||
const customerSignature = await this.contractsRepository.findSignature(
|
||||
contractId,
|
||||
'CUSTOMER',
|
||||
);
|
||||
if (!customerSignature?.stampFileId) {
|
||||
throw new BadRequestException(
|
||||
'The customer stamp is missing on this contract — it cannot be counter-signed until the customer signs again with their company stamp.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.applySignature(contract, dto, options);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -21,6 +21,7 @@ import { ContractTemplatesModule } from '../contract-templates/contract-template
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractExpiryService } from './contract-expiry.service';
|
||||
import { ContractPricingService } from './contract-pricing.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
@@ -105,6 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
providers: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
ContractExpiryService,
|
||||
ContractPricingService,
|
||||
ContractNotifierService,
|
||||
ContractTransitionService,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
import { ContractReviewNote, ContractReviewNoteType } from './entities/contract-review-note.entity';
|
||||
import { ContractSignature, ContractSignerRole } from './entities/contract-signature.entity';
|
||||
import { TERMINAL_CONTRACT_STATUSES } from './utils/contract-expiry.util';
|
||||
|
||||
export interface ContractListFilterOptions {
|
||||
statuses?: string[];
|
||||
@@ -66,6 +67,70 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return Number(row?.max ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-terminal contracts for the same company + service type, with routes
|
||||
* loaded — candidates for the duplicate-contract check on create(). Terminal
|
||||
* filtering happens in JS via isEffectivelyExpired (also covers the
|
||||
* date-passed-but-not-yet-cron-flipped case).
|
||||
*/
|
||||
async findDuplicateCandidates(
|
||||
companyId: string,
|
||||
serviceTypeId: string,
|
||||
): Promise<Contract[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.leftJoinAndSelect('contract.routes', 'routes')
|
||||
.where('contract.deleted_at IS NULL')
|
||||
.andWhere('contract.company_id = :companyId', { companyId })
|
||||
.andWhere('contract.service_type_id = :serviceTypeId', { serviceTypeId })
|
||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Nightly expiry sweep: flips lapsed contracts to EXPIRED. Returns the
|
||||
* number of rows updated (for cron logging).
|
||||
*/
|
||||
async expireLapsedContracts(): Promise<number> {
|
||||
const result = await this.repository
|
||||
.createQueryBuilder()
|
||||
.update(Contract)
|
||||
.set({ status: 'EXPIRED' })
|
||||
.where('deleted_at IS NULL')
|
||||
.andWhere('status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES })
|
||||
.andWhere('contract_valid_until IS NOT NULL AND contract_valid_until < :now', {
|
||||
now: new Date(),
|
||||
})
|
||||
.execute();
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live contracts whose validity ends between `days` and `days + 1` days from
|
||||
* now — the slice the daily expiry-reminder cron warns about. The window is
|
||||
* rolling and exactly 24h wide, so consecutive daily runs tile it without
|
||||
* gaps or overlaps: each contract is picked up by exactly one run and the
|
||||
* customer is notified once, with no "already reminded" flag to store.
|
||||
*/
|
||||
async findExpiringInDays(days: number): Promise<Contract[]> {
|
||||
const now = Date.now();
|
||||
return this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.where('contract.deleted_at IS NULL')
|
||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
.andWhere('contract.contract_valid_until >= :from', {
|
||||
from: new Date(now + days * 86_400_000),
|
||||
})
|
||||
.andWhere('contract.contract_valid_until < :to', {
|
||||
to: new Date(now + (days + 1) * 86_400_000),
|
||||
})
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||
if (!id) return null;
|
||||
@@ -434,7 +499,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
findSignatures(contractId: string): Promise<ContractSignature[]> {
|
||||
return this.dataSource.getRepository(ContractSignature).find({
|
||||
where: { contractId },
|
||||
relations: ['signatureFile'],
|
||||
relations: ['signatureFile', 'stampFile'],
|
||||
order: { signedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
@@ -445,7 +510,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
): Promise<ContractSignature | null> {
|
||||
return this.dataSource.getRepository(ContractSignature).findOne({
|
||||
where: { contractId, role },
|
||||
relations: ['signatureFile'],
|
||||
relations: ['signatureFile', 'stampFile'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
@@ -24,6 +25,7 @@ import { ContractListSummaryDto } from './dto/contract-list-summary.dto';
|
||||
import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from './entities/contract.entity';
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
@@ -155,6 +157,42 @@ export class ContractsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Same customer + same service type + an overlapping route already has a
|
||||
* non-expired contract → block. A route "overlaps" if any origin/destination
|
||||
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
|
||||
* carry a single route in practice, and still correct if that changes.
|
||||
*/
|
||||
private async assertNoDuplicateContract(
|
||||
companyId: string,
|
||||
serviceTypeId: string,
|
||||
routes: CreateContractDto['routes'],
|
||||
): Promise<void> {
|
||||
const candidates = await this.contractsRepository.findDuplicateCandidates(
|
||||
companyId,
|
||||
serviceTypeId,
|
||||
);
|
||||
const duplicate = candidates.find(
|
||||
(c) =>
|
||||
!isEffectivelyExpired(c) &&
|
||||
(c.routes ?? []).some((existingRoute) =>
|
||||
routes.some(
|
||||
(r) =>
|
||||
r.originYardId === existingRoute.originYardId &&
|
||||
r.destinationYardId === existingRoute.destinationYardId,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (duplicate) {
|
||||
const until = duplicate.contractValidUntil
|
||||
? duplicate.contractValidUntil.toISOString().slice(0, 10)
|
||||
: 'its approval completes';
|
||||
throw new ConflictException(
|
||||
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
|
||||
async create(
|
||||
dto: CreateContractDto,
|
||||
@@ -186,6 +224,9 @@ export class ContractsService {
|
||||
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
|
||||
this.assertRouteShape(dto.contractKind, dto.routes);
|
||||
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
|
||||
if (companyId) {
|
||||
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
|
||||
}
|
||||
|
||||
// Stamp the operational profile for portal scoping. A forwarder contract
|
||||
// pins its profile explicitly (trade direction can't tell it apart from a
|
||||
@@ -302,6 +343,10 @@ export class ContractsService {
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
// Hazard class / UN number only exist on a hazardous contract — a stale
|
||||
// pair from an earlier draft must never survive the flag being turned off.
|
||||
hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
|
||||
unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
contractType: dto.contractType ?? null,
|
||||
status: 'DRAFT',
|
||||
@@ -474,6 +519,13 @@ export class ContractsService {
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
// Same rule as create: clearing the flag clears the declaration with it.
|
||||
hazardClass: (dto.isHazardous ?? existing.isHazardous)
|
||||
? (dto.hazardClass ?? existing.hazardClass ?? null)
|
||||
: null,
|
||||
unNumber: (dto.isHazardous ?? existing.isHazardous)
|
||||
? (dto.unNumber ?? existing.unNumber ?? null)
|
||||
: null,
|
||||
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { HAZARD_CLASS_VALUES } from '@edr/types';
|
||||
|
||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
@@ -228,6 +230,28 @@ export class CreateContractDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: HAZARD_CLASS_VALUES,
|
||||
description: 'UN/ADR dangerous-goods class. Required when isHazardous.',
|
||||
})
|
||||
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
|
||||
@IsIn(HAZARD_CLASS_VALUES, {
|
||||
message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`,
|
||||
})
|
||||
hazardClass?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'UN number of the dangerous good. Required when isHazardous.',
|
||||
})
|
||||
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(16)
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||
)
|
||||
unNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -17,6 +17,17 @@ export class SignContractDto {
|
||||
@MinLength(20)
|
||||
signatureImageBase64?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'PNG company stamp/seal image as base64 (with or without data URL prefix). ' +
|
||||
'Required for the CUSTOMER and STAFF roles — both parties must seal the ' +
|
||||
'contract before it is fully executed.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(20)
|
||||
stampImageBase64?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -29,6 +29,14 @@ export class ContractSignature extends BaseEntity {
|
||||
@JoinColumn({ name: 'signature_file_id' })
|
||||
signatureFile?: FileRecord | null;
|
||||
|
||||
/** Company stamp/seal image, uploaded alongside the drawn signature. */
|
||||
@Column({ name: 'stamp_file_id', type: 'uuid', nullable: true })
|
||||
stampFileId?: string | null;
|
||||
|
||||
@ManyToOne(() => FileRecord, { nullable: true })
|
||||
@JoinColumn({ name: 'stamp_file_id' })
|
||||
stampFile?: FileRecord | null;
|
||||
|
||||
@Column({ name: 'consent_text', type: 'text', nullable: true })
|
||||
consentText?: string | null;
|
||||
|
||||
|
||||
@@ -188,6 +188,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
/** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */
|
||||
@Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true })
|
||||
hazardClass?: string | null;
|
||||
|
||||
/** UN number of the dangerous good; null unless hazardous. */
|
||||
@Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true })
|
||||
unNumber?: string | null;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Contract } from '../entities/contract.entity';
|
||||
|
||||
/** Statuses that already mean "done/void" — a contract in one of these never blocks a duplicate. */
|
||||
export const TERMINAL_CONTRACT_STATUSES = [
|
||||
'REJECTED',
|
||||
'CANCELLED',
|
||||
'CONTRACT_CLOSED',
|
||||
'ARCHIVED',
|
||||
'EXPIRED',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* True once a contract is done, either explicitly (terminal status) or by date
|
||||
* (past contractValidUntil). Checked by date too because the nightly expiry
|
||||
* cron only flips the status once a day — this keeps same-day checks correct
|
||||
* even a few hours before the cron runs.
|
||||
*/
|
||||
export function isEffectivelyExpired(
|
||||
contract: Pick<Contract, 'status' | 'contractValidUntil'>,
|
||||
): boolean {
|
||||
if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(contract.contractValidUntil && contract.contractValidUntil < new Date());
|
||||
}
|
||||
@@ -65,25 +65,4 @@ export class CreateLocomotiveDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
overageToleranceMeters?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 4200 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
powerKw?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 300 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tractionForceKn?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxSpeedKmh?: number;
|
||||
}
|
||||
|
||||
@@ -76,15 +76,6 @@ export class Locomotive extends BaseEntity {
|
||||
@JoinColumn({ name: 'current_yard_id' })
|
||||
currentYard?: Yard | null;
|
||||
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
powerKw?: number | null;
|
||||
|
||||
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
tractionForceKn?: number | null;
|
||||
|
||||
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
maxSpeedKmh?: number | null;
|
||||
|
||||
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
||||
trainSets?: TrainSet[];
|
||||
}
|
||||
|
||||
@@ -113,9 +113,6 @@ export class LocomotivesService {
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
overageToleranceTons: dto.overageToleranceTons ?? null,
|
||||
overageToleranceMeters: dto.overageToleranceMeters ?? null,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
tractionForceKn: dto.tractionForceKn ?? null,
|
||||
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -176,11 +173,6 @@ export class LocomotivesService {
|
||||
? locomotive.currentYardId
|
||||
: (dto.currentYardId ?? null),
|
||||
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||
tractionForceKn:
|
||||
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
|
||||
maxSpeedKmh:
|
||||
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { RoutesService } from './routes.service';
|
||||
import type { RoutesRepository } from './routes.repository';
|
||||
|
||||
type StopSeq = Array<{ yardId: string; sequenceNo: number }>;
|
||||
|
||||
/** DataSource stub whose Route repository returns the given existing routes. */
|
||||
const serviceWith = (
|
||||
existing: Array<{ id: string; milestones: StopSeq }>,
|
||||
): RoutesService => {
|
||||
const dataSource = {
|
||||
getRepository: () => ({ find: async () => existing }),
|
||||
} as unknown as DataSource;
|
||||
return new RoutesService(dataSource, {} as RoutesRepository);
|
||||
};
|
||||
|
||||
const assertNotDuplicate = (
|
||||
service: RoutesService,
|
||||
yardIds: string[],
|
||||
excludeRouteId?: string,
|
||||
): Promise<void> =>
|
||||
(
|
||||
service as unknown as {
|
||||
assertNotDuplicate: (
|
||||
m: Array<{ yardId: string }>,
|
||||
id?: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).assertNotDuplicate(
|
||||
yardIds.map((yardId) => ({ yardId })),
|
||||
excludeRouteId,
|
||||
);
|
||||
|
||||
describe('RoutesService duplicate guard', () => {
|
||||
const addisAdamaDire: StopSeq = [
|
||||
{ yardId: 'addis', sequenceNo: 1 },
|
||||
{ yardId: 'adama', sequenceNo: 2 },
|
||||
{ yardId: 'dire', sequenceNo: 3 },
|
||||
];
|
||||
|
||||
it('rejects an identical stop sequence', async () => {
|
||||
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
|
||||
|
||||
await expect(
|
||||
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('allows the same endpoints with a different corridor', async () => {
|
||||
// Same origin + destination, but skipping Adama is a genuinely other route.
|
||||
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
|
||||
|
||||
await expect(
|
||||
assertNotDuplicate(service, ['addis', 'dire']),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not flag the route being edited against itself', async () => {
|
||||
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
|
||||
|
||||
await expect(
|
||||
assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('compares stops by sequence, not storage order', async () => {
|
||||
const shuffled: StopSeq = [
|
||||
{ yardId: 'dire', sequenceNo: 3 },
|
||||
{ yardId: 'addis', sequenceNo: 1 },
|
||||
{ yardId: 'adama', sequenceNo: 2 },
|
||||
];
|
||||
const service = serviceWith([{ id: 'r1', milestones: shuffled }]);
|
||||
|
||||
await expect(
|
||||
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { TrainScheduleStatus } from '@edr/types';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
import { DataSource, In, Not } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto';
|
||||
import { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
||||
import { formatRouteLabel, Route } from './entities/route.entity';
|
||||
import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity';
|
||||
import { RoutesRepository } from './routes.repository';
|
||||
|
||||
/** Order-insensitive key: distances are symmetric. */
|
||||
@@ -88,6 +88,7 @@ export class RoutesService {
|
||||
|
||||
async create(dto: CreateRouteDto): Promise<Route> {
|
||||
const validated = await this.validateMilestones(dto.milestones);
|
||||
await this.assertNotDuplicate(validated.milestones);
|
||||
|
||||
const route = await this.dataSource.transaction(async (manager) => {
|
||||
const savedRoute = await manager.getRepository(Route).save(
|
||||
@@ -123,6 +124,11 @@ export class RoutesService {
|
||||
? await this.validateMilestones(dto.milestones)
|
||||
: null;
|
||||
|
||||
// An edit can collide with another route just as easily as a create can.
|
||||
if (milestoneInput) {
|
||||
await this.assertNotDuplicate(milestoneInput.milestones, id);
|
||||
}
|
||||
|
||||
// Milestones or endpoints are about to be rewritten — reject if any
|
||||
// non-terminal schedule still references this route, otherwise its stop list
|
||||
// and distances would silently shift under a live plan. Status-only /
|
||||
@@ -187,6 +193,51 @@ export class RoutesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
|
||||
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
|
||||
* duplicate test compares the full yard sequence, not just origin/destination.
|
||||
*
|
||||
* Decommissioned routes (STOP_WORKING) are ignored: replacing a retired
|
||||
* corridor with a fresh one is exactly what an admin does after deactivating,
|
||||
* and there is no reactivate action to fall back on.
|
||||
*/
|
||||
private async assertNotDuplicate(
|
||||
milestones: Array<{ yardId: string }>,
|
||||
excludeRouteId?: string,
|
||||
): Promise<void> {
|
||||
const signature = milestones.map((m) => m.yardId).join('>');
|
||||
|
||||
const candidates = await this.dataSource.getRepository(Route).find({
|
||||
where: {
|
||||
originYardId: milestones[0].yardId,
|
||||
destinationYardId: milestones[milestones.length - 1].yardId,
|
||||
status: Not<RouteStatus>('STOP_WORKING'),
|
||||
},
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
milestones: { yard: true },
|
||||
},
|
||||
});
|
||||
|
||||
const duplicate = candidates.find((route) => {
|
||||
if (route.id === excludeRouteId) return false;
|
||||
const stops = [...(route.milestones ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m) => m.yardId)
|
||||
.join('>');
|
||||
return stops === signature;
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
throw new ConflictException(
|
||||
`This route already exists: ${formatRouteLabel(duplicate)}. ` +
|
||||
'Edit the existing route instead of creating a duplicate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||
if (milestones.length < 2) {
|
||||
throw new BadRequestException('A route requires at least two yards');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { allowedRateUnits, isBulkQuantityUnit } from "./rate-unit.util";
|
||||
|
||||
/**
|
||||
* A bulk rate's weighting unit follows how its commodity is counted: wheat is
|
||||
* weighed (per ton), machinery is counted (per item). Per-wagon is offered
|
||||
* either way.
|
||||
*/
|
||||
describe("allowedRateUnits — bulk unit of measure", () => {
|
||||
it("offers per-ton for a weighed commodity", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "BULK",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_TON",
|
||||
}),
|
||||
).toEqual(["PER_TON", "PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("offers per-item for a counted commodity", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "BULK",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_ITEM", "PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("falls back to per-ton when the rate is not scoped to a commodity", () => {
|
||||
expect(allowedRateUnits({ appliesTo: "BULK", trigger: "ALWAYS" })).toEqual([
|
||||
"PER_TON",
|
||||
"PER_WAGON",
|
||||
]);
|
||||
});
|
||||
|
||||
it("swaps the per-ton slot for counted commodities on every bulk-capable shape", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "OTHER",
|
||||
trigger: "CUSTOMS_CLEARANCE",
|
||||
cargoKind: "BULK",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_ITEM", "PER_WAGON"]);
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "INTERCITY",
|
||||
trigger: "ALWAYS",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_CONTAINER", "PER_ITEM", "PER_WAGON", "PER_KM"]);
|
||||
});
|
||||
|
||||
it("never offers per-item for overweight, which is always per excess ton", () => {
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "OTHER",
|
||||
trigger: "OVERWEIGHT",
|
||||
cargoUnitOfMeasure: "PER_TON",
|
||||
}),
|
||||
).toEqual(["PER_TON"]);
|
||||
});
|
||||
|
||||
it("treats per-ton and per-item as the same booking quantity", () => {
|
||||
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
|
||||
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);
|
||||
expect(isBulkQuantityUnit("PER_WAGON")).toBe(false);
|
||||
expect(isBulkQuantityUnit("FLAT")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
|
||||
/** How the bulk commodity a rate is scoped to is counted (cargo_types.unit_of_measure). */
|
||||
export type CargoUom = 'PER_TON' | 'PER_ITEM' | null | undefined;
|
||||
|
||||
/**
|
||||
* Units billed against a booking's bulk quantity. That quantity is recorded in
|
||||
* the commodity's own unit — tonnes for a PER_TON commodity, item count for a
|
||||
* PER_ITEM one — so both units scale off the same field and only differ in what
|
||||
* they are called.
|
||||
*/
|
||||
export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
unit === 'PER_TON' || unit === 'PER_ITEM';
|
||||
|
||||
/**
|
||||
* Which rate units make sense for a given rate shape. The weighting basis is
|
||||
* driven by the *type* of thing being billed — a container leg bills per
|
||||
@@ -8,6 +20,10 @@ import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
|
||||
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||
* only pick a unit the pricing engine knows how to apply.
|
||||
*
|
||||
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
|
||||
* PER_ITEM wherever a weighed commodity offers PER_TON — machinery is priced
|
||||
* per unit shipped, wheat per tonne. Per-wagon is offered either way.
|
||||
*
|
||||
* Returned lists are ordered with the most natural/default unit first.
|
||||
*/
|
||||
export function allowedRateUnits(input: {
|
||||
@@ -15,6 +31,19 @@ export function allowedRateUnits(input: {
|
||||
trigger: RateTrigger;
|
||||
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
}): RateUnit[] {
|
||||
const units = unitsForShape(input);
|
||||
return input.cargoUnitOfMeasure === 'PER_ITEM'
|
||||
? units.map((u) => (u === 'PER_TON' ? 'PER_ITEM' : u))
|
||||
: units;
|
||||
}
|
||||
|
||||
function unitsForShape(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
}): RateUnit[] {
|
||||
const { appliesTo, trigger } = input;
|
||||
|
||||
@@ -81,6 +110,7 @@ export function isRateUnitAllowed(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
unit: RateUnit;
|
||||
}): boolean {
|
||||
return allowedRateUnits(input).includes(input.unit);
|
||||
|
||||
@@ -34,6 +34,9 @@ export type RateStatus = typeof RATE_STATUSES[number];
|
||||
export const RATE_UNITS = [
|
||||
'PER_WAGON',
|
||||
'PER_TON',
|
||||
// Break-bulk commodities are counted, not weighed (cargo_types.unit_of_measure
|
||||
// = PER_ITEM) — their rates bill per item off the same booking quantity field.
|
||||
'PER_ITEM',
|
||||
'PER_CONTAINER',
|
||||
'PER_KM',
|
||||
'PER_INVOICE',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Inject, Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.entity';
|
||||
import { Rate, RateTrigger } from './entities/rate.entity';
|
||||
import { isBulkQuantityUnit } from './entities/rate-unit.util';
|
||||
import {
|
||||
ICargoTypesRepository,
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
@@ -377,6 +378,9 @@ export class RuleEngineService {
|
||||
let calculatedAmount: number;
|
||||
|
||||
switch (rate.rateUnit) {
|
||||
// PER_ITEM is PER_TON for a counted (break-bulk) commodity — the bulk
|
||||
// quantity is recorded in the commodity's own unit either way.
|
||||
case 'PER_ITEM':
|
||||
case 'PER_TON':
|
||||
// OVERWEIGHT bills the excess tons; every other PER_TON surcharge
|
||||
// (e.g. bulk reefer) bills the full bulk tonnage.
|
||||
@@ -608,7 +612,7 @@ export class RuleEngineService {
|
||||
if (!rate) return modifiers;
|
||||
|
||||
const billedQty =
|
||||
rate.rateUnit === 'PER_TON'
|
||||
isBulkQuantityUnit(rate.rateUnit)
|
||||
? Math.max(0, Number(input.bulkTons ?? 0))
|
||||
: rate.rateUnit === 'PER_WAGON'
|
||||
? Math.max(0, Number(input.bulkWagons ?? 0))
|
||||
|
||||
@@ -12,7 +12,11 @@ import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import { CargoUom, allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
||||
import {
|
||||
CARGO_TYPES_REPOSITORY,
|
||||
ICargoTypesRepository,
|
||||
} from '../interfaces/cargo-types.repository.interface';
|
||||
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||
|
||||
@@ -32,6 +36,8 @@ export class RatesService {
|
||||
private readonly repository: IRatesRepository,
|
||||
@Inject(YARDS_REPOSITORY)
|
||||
private readonly yardsRepository: IYardsRepository,
|
||||
@Inject(CARGO_TYPES_REPOSITORY)
|
||||
private readonly cargoTypesRepository: ICargoTypesRepository,
|
||||
) {}
|
||||
|
||||
/** List rates — standard paginated envelope with server-side search. */
|
||||
@@ -63,25 +69,37 @@ export class RatesService {
|
||||
* Normalise + validate the weighting unit for a rate shape. Overweight is
|
||||
* always billed per excess ton, so its unit is forced to PER_TON regardless
|
||||
* of what the client sent. Every other shape must pick a unit the pricing
|
||||
* engine can actually apply (see `allowedRateUnits`).
|
||||
* engine can actually apply (see `allowedRateUnits`) — for a rate scoped to a
|
||||
* bulk commodity that means the commodity's own unit of measure: a PER_ITEM
|
||||
* commodity bills per item where a weighed one bills per ton.
|
||||
*/
|
||||
private resolveRateUnit(
|
||||
private async resolveRateUnit(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null,
|
||||
): Rate['rateUnit'] {
|
||||
cargoTypeId?: string | null,
|
||||
): Promise<Rate['rateUnit']> {
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind });
|
||||
const cargoUnitOfMeasure = await this.cargoUnitOfMeasure(cargoTypeId);
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger, cargoKind, cargoUnitOfMeasure });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, cargoKind, unit: requestedUnit })) {
|
||||
if (
|
||||
!isRateUnitAllowed({
|
||||
appliesTo,
|
||||
trigger,
|
||||
cargoKind,
|
||||
cargoUnitOfMeasure,
|
||||
unit: requestedUnit,
|
||||
})
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
@@ -89,6 +107,13 @@ export class RatesService {
|
||||
return requestedUnit;
|
||||
}
|
||||
|
||||
/** Unit of measure of the bulk commodity a rate is scoped to; null when unscoped. */
|
||||
private async cargoUnitOfMeasure(cargoTypeId?: string | null): Promise<CargoUom> {
|
||||
if (!cargoTypeId) return null;
|
||||
const cargo = await this.cargoTypesRepository.findById(cargoTypeId);
|
||||
return cargo?.unitOfMeasure ?? null;
|
||||
}
|
||||
|
||||
/** Base rail freight is priced per leg; surcharges and truck legs are not. */
|
||||
private isBaseFreight(appliesTo: Rate['appliesTo'], trigger: Rate['trigger']): boolean {
|
||||
return trigger === 'ALWAYS' && BASE_FREIGHT_CATEGORIES.includes(appliesTo);
|
||||
@@ -380,11 +405,12 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
cargoKind,
|
||||
cargoTypeId,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
@@ -562,12 +588,19 @@ export class RatesService {
|
||||
// Re-validate the unit against the (possibly changed) shape; overweight is
|
||||
// forced to PER_TON.
|
||||
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
||||
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit, cargoKind);
|
||||
const rateUnit = await this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
requestedUnit,
|
||||
cargoKind,
|
||||
updates.cargoTypeId,
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
rateUnit: updates.rateUnit,
|
||||
rateUnit,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
cargoTypeId: updates.cargoTypeId,
|
||||
tradeDirection: updates.tradeDirection,
|
||||
|
||||
@@ -3000,6 +3000,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many bookings on this route-day would be expired if document review
|
||||
* ended right now — i.e. requests staff have neither accepted nor rejected.
|
||||
* Same query the doc-review-end sweep runs, so the number staff see is
|
||||
* exactly what is at risk.
|
||||
*/
|
||||
async countUnacceptedForRouteDay(group: RouteDayGroup): Promise<number> {
|
||||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||
if (corridorYards.length === 0) return 0;
|
||||
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||||
corridorYards,
|
||||
group.day,
|
||||
);
|
||||
return unaccepted.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireLeftoverDayPool: jest.Mock;
|
||||
expireLeftoverExportDay: jest.Mock;
|
||||
fillFromWaitingList: jest.Mock;
|
||||
countUnacceptedForRouteDay: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
@@ -79,6 +80,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
|
||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||
countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
@@ -267,4 +269,87 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(batch.setWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- header alarm ---------------------------------------------------------
|
||||
|
||||
describe('getDocReviewAlert', () => {
|
||||
const reviewing = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
...over,
|
||||
});
|
||||
|
||||
it('returns null when nothing is under document review', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
baseSchedule({ windowPhase: 'OPEN' }),
|
||||
]);
|
||||
expect(await service.getDocReviewAlert()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when every request on the route-day is decided', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(0);
|
||||
expect(await service.getDocReviewAlert()).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the deadline, its own pending count and the phase length', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(3);
|
||||
|
||||
const alert = await service.getDocReviewAlert();
|
||||
|
||||
expect(alert).toMatchObject({
|
||||
scheduleId,
|
||||
originYardId: 'yard-o',
|
||||
destinationYardId: 'yard-d',
|
||||
tradeDirection: 'IMPORT',
|
||||
pendingCount: 3,
|
||||
docReviewMinutes: 30,
|
||||
docReviewEndsAt: '2026-07-01T01:30:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the nearest deadline when it has nothing pending', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
reviewing({
|
||||
id: 'sched-later',
|
||||
destinationStationId: 'yard-far',
|
||||
docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'),
|
||||
}),
|
||||
reviewing({ id: 'sched-soon' }),
|
||||
]);
|
||||
// Nearest (sched-soon, yard-d) is clear; the later route-day still isn't.
|
||||
batch.countUnacceptedForRouteDay.mockImplementation(
|
||||
async (g: { destinationYardId: string }) =>
|
||||
g.destinationYardId === 'yard-far' ? 2 : 0,
|
||||
);
|
||||
|
||||
const alert = await service.getDocReviewAlert();
|
||||
|
||||
expect(alert?.scheduleId).toBe('sched-later');
|
||||
expect(alert?.pendingCount).toBe(2);
|
||||
});
|
||||
|
||||
it('counts a route-day once when sibling trains share the review phase', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
reviewing({ id: 'sched-a' }),
|
||||
reviewing({ id: 'sched-b' }),
|
||||
]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(4);
|
||||
|
||||
const alert = await service.getDocReviewAlert();
|
||||
|
||||
expect(alert?.pendingCount).toBe(4);
|
||||
expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a phase staff already completed early', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }),
|
||||
]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(5);
|
||||
expect(await service.getDocReviewAlert()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,28 @@ import {
|
||||
} from './batch-window.util';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
|
||||
/**
|
||||
* The most urgent document-review deadline that still has un-accepted booking
|
||||
* requests behind it. Backoffice counts down to it and warns staff, because
|
||||
* everything still pending when the phase ends is expired automatically.
|
||||
*/
|
||||
export interface DocReviewAlert {
|
||||
/** A schedule of the route-day group under review (deep-link target). */
|
||||
scheduleId: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT booking day of the group, YYYY-MM-DD. */
|
||||
day: string;
|
||||
/** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
|
||||
tradeDirection: string;
|
||||
/** ISO deadline the review phase ends at. */
|
||||
docReviewEndsAt: string;
|
||||
/** Full length of the review phase — the client warns past its halfway mark. */
|
||||
docReviewMinutes: number;
|
||||
/** Requests neither accepted nor rejected — they expire at the deadline. */
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
|
||||
* booking window for EXPORT schedules. All state lives in DB timestamps on the
|
||||
@@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The route-day currently in document review whose deadline is nearest and
|
||||
* which still has un-accepted requests. Null when nothing is under review or
|
||||
* every request has been decided — the backoffice header shows nothing then.
|
||||
*
|
||||
* One card, one deadline, one count: route-days are checked in deadline order
|
||||
* and the first with pending work wins, so the number always belongs to the
|
||||
* clock beside it.
|
||||
*/
|
||||
async getDocReviewAlert(): Promise<DocReviewAlert | null> {
|
||||
const reviewing = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(s) =>
|
||||
s.windowPhase === 'DOC_REVIEW' &&
|
||||
s.docReviewCompletedAt == null &&
|
||||
s.docReviewEndsAt != null &&
|
||||
s.scheduledDepartureDate != null,
|
||||
)
|
||||
.sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
|
||||
if (reviewing.length === 0) return null;
|
||||
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const seen = new Set<string>();
|
||||
for (const schedule of reviewing) {
|
||||
const group = {
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
};
|
||||
// Sibling trains share one review phase for the route-day pool — count it once.
|
||||
const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const pendingCount =
|
||||
await this.bookingBatchService.countUnacceptedForRouteDay(group);
|
||||
if (pendingCount === 0) continue;
|
||||
|
||||
return {
|
||||
scheduleId: schedule.id,
|
||||
...group,
|
||||
// Carried so the backoffice list opens on the same direction the
|
||||
// at-risk requests belong to (import corridor, or a domestic day).
|
||||
tradeDirection: schedule.direction ?? 'IMPORT',
|
||||
docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
|
||||
docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Staff finished document review early — start the batch/payment phase now. */
|
||||
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
BookingDocReviewAlert,
|
||||
TrainSchedulingCancel,
|
||||
TrainSchedulingCreate,
|
||||
TrainSchedulingReschedule,
|
||||
@@ -747,6 +748,18 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Get("doc-review-alert")
|
||||
// Dedicated permission, not scheduling or bookings:view — the alarm is meant
|
||||
// for the position types that actually decide operation requests.
|
||||
@BookingDocReviewAlert()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Nearest document-review deadline that still has un-accepted booking requests behind it (null when there is none) — drives the backoffice header countdown",
|
||||
})
|
||||
async getDocReviewAlert() {
|
||||
return this.bookingWindowService.getDocReviewAlert();
|
||||
}
|
||||
|
||||
@Post("schedules/:id/doc-review-complete")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -6659,6 +6659,8 @@ export class TrainSchedulingService {
|
||||
: slot.physicalWagonId ?? null;
|
||||
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||||
}
|
||||
// Fallback only — real empty rows below carry the wagon's OWN physical
|
||||
// sequenceNumber, not an invented tail position (see emptyConsistWagons).
|
||||
const maxSlotSequenceNo = Math.max(
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
@@ -6669,7 +6671,11 @@ export class TrainSchedulingService {
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
// The wagon's REAL coupling position, so an empty wagon in the middle
|
||||
// of the train draws in the middle — not appended after every loaded
|
||||
// slot. Falls back to a tail position only if the wagon somehow has
|
||||
// no sequence number of its own.
|
||||
sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
@@ -6779,8 +6785,7 @@ export class TrainSchedulingService {
|
||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||
})),
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
wagons: (schedule.trainSet.wagons ?? [])
|
||||
.map((wagon) => {
|
||||
// Frozen schedules read the wagon number + allocations from the
|
||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||
@@ -6788,9 +6793,20 @@ export class TrainSchedulingService {
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||||
: undefined;
|
||||
// Draw the slot at its physical wagon's REAL coupling position,
|
||||
// not the planning-time slot index — the two diverge once a
|
||||
// load has been dragged onto a different wagon (moveWagonLoad
|
||||
// repoints physicalWagonId but a slot keeps its own sequenceNo),
|
||||
// or once wagon types were interleaved at pinning time. Frozen
|
||||
// and not-yet-pinned slots have no live physical wagon to trust,
|
||||
// so they keep their own slot sequence.
|
||||
const sequenceNo =
|
||||
frozenSlot || !wagon.physicalWagon
|
||||
? wagon.sequenceNo
|
||||
: (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
|
||||
return {
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
sequenceNo,
|
||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||
@@ -6859,7 +6875,12 @@ export class TrainSchedulingService {
|
||||
})) ?? [],
|
||||
};
|
||||
})
|
||||
.concat(emptyConsistWagons),
|
||||
.concat(emptyConsistWagons)
|
||||
.sort((a, b) =>
|
||||
schedule.reverseWagonOrder
|
||||
? b.sequenceNo - a.sequenceNo
|
||||
: a.sequenceNo - b.sequenceNo,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
|
||||
@@ -548,6 +548,26 @@ export class TrainBuilderService {
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */
|
||||
private async isAnyWagonPinnedToLiveSchedule(
|
||||
manager: EntityManager,
|
||||
wagonIds: string[],
|
||||
): Promise<boolean> {
|
||||
if (!wagonIds.length) return false;
|
||||
const rows: { exists: boolean }[] = await manager.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[wagonIds],
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
|
||||
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -560,6 +580,17 @@ export class TrainBuilderService {
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
// A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at
|
||||
// its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so
|
||||
// renumbering here would silently desync that schedule's drawn consist
|
||||
// from the built train's real order (loaded slots keep the old order,
|
||||
// empty ones show the new one). Same guard as remove/maintenance.
|
||||
if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) {
|
||||
throw new ConflictException(
|
||||
'This train has wagons pinned to an active schedule and cannot be reordered — ' +
|
||||
"it would desync the schedule's consist view from the built train's real order.",
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { IsArray, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReorderWagonsDto {
|
||||
@IsArray()
|
||||
@IsUUID(4, { each: true })
|
||||
wagonIds!: string[];
|
||||
}
|
||||
@@ -12,13 +12,12 @@ import {
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FleetManage, FleetView, StaffReference } from '../../common/booking-guards';
|
||||
import { FleetManage, StaffReference } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { WagonsService } from './wagons.service';
|
||||
@@ -103,17 +102,3 @@ export class WagonsController {
|
||||
return this.wagonsService.bulkSetStatus(dto);
|
||||
}
|
||||
}
|
||||
|
||||
// Separate controller for train‑specific reorder (registered in module)
|
||||
@Controller('trains/:trainId/reorder-wagons')
|
||||
@FleetView(FREIGHT_PERMS.trains.view)
|
||||
export class TrainWagonsReorderController {
|
||||
constructor(private readonly wagonsService: WagonsService) {}
|
||||
|
||||
@Post()
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Reorder wagons of a train' })
|
||||
reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) {
|
||||
return this.wagonsService.reorderWagons(trainId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonsController, TrainWagonsReorderController } from './wagons.controller';
|
||||
import { WagonsController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
@@ -22,7 +22,6 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
TrainWagonsReorderController,
|
||||
WagonTransferRequestsController,
|
||||
],
|
||||
providers: [WagonsService, WagonTransferRequestsService],
|
||||
|
||||
@@ -11,7 +11,6 @@ import { CreateWagonDto } from './dto/create-wagon.dto';
|
||||
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
|
||||
import { UpdateWagonDto } from './dto/update-wagon.dto';
|
||||
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
|
||||
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
|
||||
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
|
||||
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
@@ -392,20 +391,4 @@ export class WagonsService {
|
||||
}
|
||||
}
|
||||
|
||||
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
|
||||
const queryRunner = this.dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
try {
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await queryRunner.manager.update(Wagon, dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
await queryRunner.commitTransaction();
|
||||
} catch (err) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw err;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ The governing law shall be the laws of the Federal Democratic Republic of Ethiop
|
||||
a(
|
||||
"effectiveness",
|
||||
"Contract Effectiveness",
|
||||
`The contract shall come into full force and effect on the date when the contract is signed by the parties and witnesses.`,
|
||||
`The contract shall come into full force and effect on the date when the contract is signed by both parties.`,
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -550,7 +550,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due
|
||||
a(
|
||||
"effectiveness",
|
||||
"Contract Effectiveness",
|
||||
`The contract is valid once signed by both parties and witnesses.`,
|
||||
`The contract is valid once signed by both parties.`,
|
||||
),
|
||||
a(
|
||||
"duration",
|
||||
@@ -698,7 +698,7 @@ If terminated for cause, the terminating party must issue a 15-day written notic
|
||||
a(
|
||||
"effectiveness",
|
||||
"Contract Effectiveness",
|
||||
`The contract is valid once signed by both parties and witnesses.`,
|
||||
`The contract is valid once signed by both parties.`,
|
||||
),
|
||||
a(
|
||||
"duration",
|
||||
@@ -838,7 +838,7 @@ Notwithstanding the above, the Service Provider may revise transport tariffs due
|
||||
a(
|
||||
"effectiveness",
|
||||
"Contract Effectiveness",
|
||||
`The contract is valid once signed by both parties and witnesses.`,
|
||||
`The contract is valid once signed by both parties.`,
|
||||
),
|
||||
a(
|
||||
"duration",
|
||||
|
||||
@@ -64,6 +64,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'),
|
||||
perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'),
|
||||
perm('a1000001-0001-4000-8000-000000000024', 'edr_freight_app:bookings:create', 'Create booking'),
|
||||
// Header alarm for the document-review deadline: its own key so only the
|
||||
// position types that actually decide operation requests are alerted.
|
||||
perm('a1000001-0001-4000-8000-000000000025', 'edr_freight_app:bookings:doc_review_alert', 'See document-review deadline alarm'),
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -96,6 +99,10 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
perm('a3000001-0001-4000-8000-00000000000e', 'edr_freight_app:contracts:clearance_et_actions', 'GL Ethiopia phased clearance actions'),
|
||||
perm('a3000001-0001-4000-8000-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'),
|
||||
perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'),
|
||||
// Hazardous contracts get two extra approval steps ahead of the normal chain.
|
||||
// Each has its own permission so the two desks are genuinely separate people.
|
||||
perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'),
|
||||
perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'),
|
||||
];
|
||||
|
||||
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||
@@ -401,6 +408,7 @@ export const FREIGHT_PERMS = {
|
||||
reviewDocuments: 'edr_freight_app:bookings:review_documents',
|
||||
uploadClearanceOutput: 'edr_freight_app:bookings:upload_clearance_output',
|
||||
finalizeClearance: 'edr_freight_app:bookings:finalize_clearance',
|
||||
docReviewAlert: 'edr_freight_app:bookings:doc_review_alert',
|
||||
},
|
||||
contracts: {
|
||||
view: 'edr_freight_app:contracts:view',
|
||||
@@ -419,6 +427,8 @@ export const FREIGHT_PERMS = {
|
||||
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
||||
hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one',
|
||||
hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two',
|
||||
generateContract: 'edr_freight_app:contracts:generate_contract',
|
||||
signStaff: {
|
||||
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
|
||||
@@ -781,6 +791,9 @@ export const ROLE_PERMISSION_PRESETS = {
|
||||
operationsOfficer: [
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
FREIGHT_PERMS.bookings.operations,
|
||||
// They are the ones who accept/reject operation requests, so they are the
|
||||
// ones the doc-review countdown is for.
|
||||
FREIGHT_PERMS.bookings.docReviewAlert,
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.trainScheduling.create,
|
||||
FREIGHT_PERMS.trainScheduling.update,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Alert, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import { AlertTriangle, Send } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
|
||||
export interface BookingChangesRequestedAlertProps {
|
||||
bookingId: string;
|
||||
reference?: string | null;
|
||||
/** Operations' note — what has to change before this can go back to them. */
|
||||
note?: string | null;
|
||||
/** Shipment day the booking currently holds; the resubmit default. */
|
||||
scheduledDate?: string | null;
|
||||
/** GL Ethiopia owns customs bookings, so only they get the resubmit control. */
|
||||
canResubmit: boolean;
|
||||
onResubmitted?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operations sent a GL-created booking back for changes.
|
||||
*
|
||||
* The customer cannot act on this — GL created the booking on their behalf — so
|
||||
* the note and the way out both live here, on the page GL works from. Resubmit
|
||||
* re-requests operation on the chosen shipment day; the server re-checks the day
|
||||
* has a departure that can carry the cargo and refuses with the reason if not.
|
||||
*/
|
||||
export function BookingChangesRequestedAlert({
|
||||
bookingId,
|
||||
reference,
|
||||
note,
|
||||
scheduledDate,
|
||||
canResubmit,
|
||||
onResubmitted,
|
||||
}: BookingChangesRequestedAlertProps) {
|
||||
const [day, setDay] = useState<Date | null>(
|
||||
scheduledDate ? new Date(scheduledDate) : null,
|
||||
);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const resubmit = async () => {
|
||||
if (!day) return;
|
||||
setSending(true);
|
||||
try {
|
||||
await bookingsService.proceedToOperation(bookingId, day.toISOString());
|
||||
toast.success("Sent back to Operations for review");
|
||||
onResubmitted?.();
|
||||
} catch {
|
||||
// The http interceptor already toasts the server's own reason (no
|
||||
// departure that day, no wagon that can carry the cargo, export train
|
||||
// full…) — a second toast here would just duplicate it.
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
title={`Operations returned booking ${reference ?? ""} for changes`.trim()}
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
{note ? (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="sm"
|
||||
bg="red.0"
|
||||
style={{ borderColor: "var(--mantine-color-red-3)", width: "100%" }}
|
||||
>
|
||||
<Text size="xs" fw={700} c="red.9" tt="uppercase" mb={4}>
|
||||
What Operations asked for
|
||||
</Text>
|
||||
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{note}
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Text size="sm">
|
||||
Operations returned this booking without a note — contact them for
|
||||
the detail before resubmitting.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text size="sm">
|
||||
This booking was created by GL Ethiopia, so the customer cannot fix it.
|
||||
Make the correction Operations asked for, then send it back for review.{" "}
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/bookings/${bookingId}/clearance`}
|
||||
inherit
|
||||
fw={600}
|
||||
c="red.8"
|
||||
>
|
||||
Open the booking →
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
{canResubmit ? (
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<DateInput
|
||||
label="Shipment day"
|
||||
description="Keep the day or pick another with an open departure"
|
||||
value={day}
|
||||
onChange={(v) => setDay(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
size="sm"
|
||||
w={230}
|
||||
/>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
size="sm"
|
||||
loading={sending}
|
||||
disabled={!day}
|
||||
leftSection={<Send size={15} />}
|
||||
onClick={() => void resubmit()}
|
||||
>
|
||||
Resubmit to Operations
|
||||
</Button>
|
||||
</Group>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default BookingChangesRequestedAlert;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck, X } from "lucide-react";
|
||||
import { Check, Flame, ShieldCheck, X } from "lucide-react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
@@ -14,10 +14,22 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { HazardDeclarationPanel } from "./HazardDeclarationPanel";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
import {
|
||||
canApproveContractStep,
|
||||
CONTRACT_APPROVAL_ROLE_LABELS,
|
||||
HAZARDOUS_APPROVAL_ROLE_PERMISSION,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
/** Chain roles that exist only because the contract carries dangerous goods. */
|
||||
const isHazardStep = (requiredRole: string): boolean =>
|
||||
requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION;
|
||||
|
||||
const roleLabel = (requiredRole: string): string =>
|
||||
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin");
|
||||
@@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({
|
||||
<Text size="sm" c="dimmed">
|
||||
You are about to approve the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{pendingStep?.requiredRole}
|
||||
{roleLabel(pendingStep?.requiredRole ?? "")}
|
||||
</Text>{" "}
|
||||
step for contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
@@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({
|
||||
</Text>
|
||||
. This action cannot be undone from this screen.
|
||||
</Text>
|
||||
{pendingStep && isHazardStep(pendingStep.requiredRole) && (
|
||||
<HazardDeclarationPanel contract={contract} />
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={closeApprove}>
|
||||
Cancel
|
||||
@@ -241,7 +256,7 @@ export function ContractApprovalStepsCard({
|
||||
{ value: "CUSTOMER", label: "Customer — must resubmit" },
|
||||
...returnableSteps.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.requiredRole} — step ${s.stepOrder} re-approves`,
|
||||
label: `${roleLabel(s.requiredRole)} — step ${s.stepOrder} re-approves`,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
@@ -254,7 +269,7 @@ export function ContractApprovalStepsCard({
|
||||
</Text>{" "}
|
||||
will go back to the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{targetStep?.requiredRole}
|
||||
{roleLabel(targetStep?.requiredRole ?? "")}
|
||||
</Text>{" "}
|
||||
step. That approver fixes the contract and approves again, and
|
||||
every later step re-approves in order. The customer is not
|
||||
@@ -264,7 +279,7 @@ export function ContractApprovalStepsCard({
|
||||
<Text size="sm" c="dimmed">
|
||||
Rejecting the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{rejectStepRow?.requiredRole}
|
||||
{roleLabel(rejectStepRow?.requiredRole ?? "")}
|
||||
</Text>{" "}
|
||||
step rejects contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
@@ -300,7 +315,7 @@ export function ContractApprovalStepsCard({
|
||||
onClick={runReject}
|
||||
>
|
||||
{sendBack
|
||||
? `Send back to ${targetStep?.requiredRole ?? "step"}`
|
||||
? `Send back to ${targetStep ? roleLabel(targetStep.requiredRole) : "step"}`
|
||||
: "Reject contract"}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -333,6 +348,7 @@ function StepRow({
|
||||
: isNext
|
||||
? "edr-green"
|
||||
: "gray";
|
||||
const hazard = isHazardStep(step.requiredRole);
|
||||
|
||||
return (
|
||||
<Group
|
||||
@@ -343,11 +359,19 @@ function StepRow({
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderLeft: isNext
|
||||
? "3px solid var(--freight-brand)"
|
||||
border: hazard
|
||||
? "1px solid #F3D5D0"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||
borderLeft: isNext
|
||||
? `3px solid ${hazard ? "#C0392B" : "var(--freight-brand)"}`
|
||||
: hazard
|
||||
? "1px solid #F3D5D0"
|
||||
: "1px solid var(--mantine-color-gray-2)",
|
||||
background: hazard
|
||||
? "#FEF7F6"
|
||||
: isNext
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "white",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
@@ -371,9 +395,23 @@ function StepRow({
|
||||
{step.stepOrder}
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" align="center">
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{roleLabel(step.requiredRole)}
|
||||
</Text>
|
||||
{hazard && (
|
||||
<Badge
|
||||
color="red"
|
||||
variant="light"
|
||||
size="xs"
|
||||
radius="sm"
|
||||
leftSection={<Flame size={10} />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Hazmat
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{step.note && (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.note}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
// Select, // ponytail: unused now the validity dropdown below is commented out
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
@@ -75,8 +76,10 @@ export function ContractDocumentEditorModal({
|
||||
onClose,
|
||||
contractId,
|
||||
mode,
|
||||
validityOptions = [],
|
||||
validityLoading = false,
|
||||
// ponytail: validityOptions/validityLoading fed the now-commented dropdown
|
||||
// above — caller still passes them, left unread here for a quick revert.
|
||||
// validityOptions = [],
|
||||
// validityLoading = false,
|
||||
accepting = false,
|
||||
saving = false,
|
||||
onAccept,
|
||||
@@ -93,7 +96,12 @@ export function ContractDocumentEditorModal({
|
||||
const [documentTitle, setDocumentTitle] = useState("");
|
||||
const [whereasClauses, setWhereasClauses] = useState<string[]>([]);
|
||||
const [articles, setArticles] = useState<EditableArticle[]>([]);
|
||||
const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
// const [validityDays, setValidityDays] = useState<string | null>(null);
|
||||
// ponytail: client keeps flip-flopping the validity requirement — swapped
|
||||
// the validity dropdown for explicit start/end dates, kept above commented
|
||||
// instead of deleted so it's a one-line revert if they flip back.
|
||||
const [validityStart, setValidityStart] = useState<Date | null>(null);
|
||||
const [validityEnd, setValidityEnd] = useState<Date | null>(null);
|
||||
|
||||
// Seed the editor from the loaded draft whenever the dialog (re)opens.
|
||||
useEffect(() => {
|
||||
@@ -110,11 +118,11 @@ export function ContractDocumentEditorModal({
|
||||
}, [opened, draft]);
|
||||
|
||||
// Default validity to the first configured option (accept mode).
|
||||
useEffect(() => {
|
||||
if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||
setValidityDays(validityOptions[0].value);
|
||||
}
|
||||
}, [mode, validityDays, validityOptions]);
|
||||
// useEffect(() => {
|
||||
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
|
||||
// setValidityDays(validityOptions[0].value);
|
||||
// }
|
||||
// }, [mode, validityDays, validityOptions]);
|
||||
|
||||
// Editing rights belong to the approver whose turn it is, so the server
|
||||
// decides per-caller — the client cannot derive this from the contract alone.
|
||||
@@ -169,8 +177,13 @@ export function ContractDocumentEditorModal({
|
||||
const submit = () => {
|
||||
const snapshot = buildSnapshot();
|
||||
if (mode === "accept") {
|
||||
const days = Number(validityDays);
|
||||
if (!days) return;
|
||||
// const days = Number(validityDays);
|
||||
// if (!days) return;
|
||||
if (!validityStart || !validityEnd) return;
|
||||
const days = Math.ceil(
|
||||
(validityEnd.getTime() - validityStart.getTime()) / (24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (days <= 0) return;
|
||||
onAccept?.(days, snapshot);
|
||||
} else {
|
||||
onSaveEdit?.(snapshot);
|
||||
@@ -181,7 +194,8 @@ export function ContractDocumentEditorModal({
|
||||
const canSubmit =
|
||||
hasArticles &&
|
||||
!locked &&
|
||||
(mode === "edit" || Boolean(validityDays)) &&
|
||||
// (mode === "edit" || Boolean(validityDays)) &&
|
||||
(mode === "edit" || Boolean(validityStart && validityEnd)) &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
@@ -375,7 +389,10 @@ export function ContractDocumentEditorModal({
|
||||
|
||||
{mode === "accept" && (
|
||||
<>
|
||||
{validityOptions.length > 0 ? (
|
||||
{/* ponytail: client keeps changing this requirement — swapped
|
||||
the validity-period dropdown for explicit start/end dates,
|
||||
left the old block commented instead of deleted. */}
|
||||
{/* {validityOptions.length > 0 ? (
|
||||
<Select
|
||||
label="Contract validity"
|
||||
placeholder="Select a validity period"
|
||||
@@ -391,7 +408,25 @@ export function ContractDocumentEditorModal({
|
||||
? "Loading validity periods…"
|
||||
: "No validity periods are configured yet. Add them under Dropdown Settings."}
|
||||
</Text>
|
||||
)}
|
||||
)} */}
|
||||
<Group grow align="flex-start">
|
||||
<DateInput
|
||||
label="Start date"
|
||||
placeholder="Contract validity start"
|
||||
value={validityStart}
|
||||
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
|
||||
maxDate={validityEnd ?? undefined}
|
||||
clearable
|
||||
/>
|
||||
<DateInput
|
||||
label="End date"
|
||||
placeholder="Contract validity end"
|
||||
value={validityEnd}
|
||||
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
|
||||
minDate={validityStart ?? undefined}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Badge, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { Flame } from "lucide-react";
|
||||
import { hazardClassLabel, type Freight } from "@edr/types";
|
||||
|
||||
/**
|
||||
* The contract's dangerous-goods declaration — the UN/ADR class and UN number
|
||||
* the customer declared alongside the hazard documents. Shown wherever a
|
||||
* hazardous contract is reviewed: the cargo-scope card and the two hazardous
|
||||
* approval confirmations, so no one signs off without seeing what is moving.
|
||||
*/
|
||||
export function HazardDeclarationPanel({
|
||||
contract,
|
||||
}: {
|
||||
contract: Pick<Freight.IContract, "hazardClass" | "unNumber">;
|
||||
}) {
|
||||
const classLabel = hazardClassLabel(contract.hazardClass);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="sm"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
px="md"
|
||||
py="sm"
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px solid #F3D5D0",
|
||||
background: "#FEF7F6",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FBEAE7",
|
||||
color: "#C0392B",
|
||||
}}
|
||||
>
|
||||
<Flame size={16} />
|
||||
</Box>
|
||||
<Stack gap={6} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={700}>
|
||||
Dangerous goods declaration
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Badge color="red" variant="light" radius="sm" size="sm">
|
||||
{classLabel ?? "Class not declared"}
|
||||
</Badge>
|
||||
<Badge color="red" variant="light" radius="sm" size="sm">
|
||||
{contract.unNumber ? `UN ${contract.unNumber}` : "UN number not declared"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Check the declaration against the uploaded hazard documents before
|
||||
approving.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
|
||||
import { RefreshCw, Stamp, X } from "lucide-react";
|
||||
|
||||
const MAX_STAMP_MB = 5;
|
||||
|
||||
export interface StampUploadProps {
|
||||
/** Stamp image as a data URL, or null when none is attached yet. */
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Company stamp/seal attachment for the contract signing modal. Reads the
|
||||
* picked image straight into a data URL because the signing endpoint takes
|
||||
* base64 in JSON (same transport as the drawn signature), not multipart.
|
||||
*/
|
||||
export function StampUpload({
|
||||
value,
|
||||
onChange,
|
||||
label = "Company stamp",
|
||||
description = "Attach your official company stamp or seal.",
|
||||
}: StampUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const readFile = (file: File | undefined | null) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("The stamp must be an image file (PNG or JPG).");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_STAMP_MB * 1024 * 1024) {
|
||||
setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setError(null);
|
||||
setFileName(file.name);
|
||||
onChange(typeof reader.result === "string" ? reader.result : null);
|
||||
};
|
||||
reader.onerror = () => setError("Could not read that file. Try another.");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const openPicker = () => inputRef.current?.click();
|
||||
|
||||
const clear = () => {
|
||||
setFileName(null);
|
||||
setError(null);
|
||||
onChange(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onChange={(e) => readFile(e.currentTarget.files?.[0])}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={value}
|
||||
alt="Company stamp"
|
||||
fit="contain"
|
||||
h={92}
|
||||
w={92}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{fileName ?? "Stamp attached"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
This stamp is applied next to your signature on the contract.
|
||||
</Text>
|
||||
<Group gap="xs" mt={2}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
onClick={openPicker}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<X size={13} />}
|
||||
onClick={clear}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
onClick={openPicker}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
readFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
style={{
|
||||
borderColor: dragging
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: dragging
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Stamp size={26} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text size="sm" fw={500}>
|
||||
Upload company stamp
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{description} Drop an image here or click to browse — PNG or JPG,
|
||||
up to {MAX_STAMP_MB} MB.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" c="red.7">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export function computeGlShipmentTotal(
|
||||
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||
if (lashing && lashing.unit === "per_ton") {
|
||||
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
|
||||
const tons = q.bulkQuantity;
|
||||
if (tons > 0) {
|
||||
lines.push({
|
||||
@@ -199,7 +199,7 @@ export function computeGlShipmentTotal(
|
||||
cl.unit === "per_wagon"
|
||||
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton") {
|
||||
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
|
||||
qty = q.bulkQuantity;
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import DocReviewAlertButton from "@/features/bookingWindows/DocReviewAlertButton";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
@@ -114,8 +115,12 @@ const FreightDashboardHeader = ({
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Right: actions + avatar */}
|
||||
{/* Right: actions + avatar. The doc-review alarm leads the group — it
|
||||
only renders in the last half of a review phase that still has
|
||||
undecided requests, so it never competes for space otherwise. */}
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<DocReviewAlertButton />
|
||||
|
||||
<Tooltip label="Language" withArrow openDelay={300}>
|
||||
<UnstyledButton className={ISLAND} aria-label="Language">
|
||||
<Languages size={17} strokeWidth={1.8} />
|
||||
|
||||
@@ -156,6 +156,8 @@ export const URL_CONSTANTS = {
|
||||
`/bookings/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||
`/bookings/${id}/clearance/export-release`,
|
||||
// Re-request operation after Operations sent the booking back for changes.
|
||||
CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`,
|
||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||
},
|
||||
@@ -294,6 +296,7 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/run-allocation`,
|
||||
DOC_REVIEW_COMPLETE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/doc-review-complete`,
|
||||
DOC_REVIEW_ALERT: "/train-scheduling/doc-review-alert",
|
||||
ASSIGN_UNASSIGNED_BOOKING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
BOOKING_WINDOW: (id: string) =>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Text, Tooltip, UnstyledButton } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, ChevronRight } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canSeeDocReviewAlert } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
/**
|
||||
* Booking-request statuses that count as "nobody decided yet". These are the
|
||||
* exact statuses the window engine expires when document review ends
|
||||
* (findUnacceptedForRouteDay), so the deep-linked list shows precisely the
|
||||
* requests the countdown is warning about.
|
||||
*/
|
||||
const UNDECIDED_STATUSES = [
|
||||
"OPERATION_REQUESTED",
|
||||
"OPERATION_REQUEST_PENDING",
|
||||
"OPERATION_CHANGES_REQUESTED",
|
||||
"OPERATION_PRICE_PENDING_CONFIRM",
|
||||
].join(",");
|
||||
|
||||
const pendingRequestsHref = (tradeDirection: string) =>
|
||||
`/dashboard/booking-requests?statuses=${UNDECIDED_STATUSES}&tradeDirection=${tradeDirection}`;
|
||||
|
||||
/** mm:ss (or h:mm:ss past an hour), fixed width so the pill never jitters. */
|
||||
function formatRemaining(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
const mm = String(minutes).padStart(2, "0");
|
||||
const ss = String(seconds).padStart(2, "0");
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header alarm for the document-review deadline. Appears only once the review
|
||||
* phase is half spent AND requests are still undecided — everything still
|
||||
* pending when the clock runs out is expired automatically, so this is the last
|
||||
* call to accept or reject. Clicking opens the booking requests already
|
||||
* filtered to those undecided import requests.
|
||||
*/
|
||||
export default function DocReviewAlertButton() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: alert } = useQuery({
|
||||
...api.trainScheduling.docReviewAlert.queryOptions(),
|
||||
// Dedicated permission: only the position types granted it are alarmed.
|
||||
enabled: canSeeDocReviewAlert(user),
|
||||
// The window engine ticks every 10s; a minute is close enough for a header
|
||||
// chip — the countdown itself runs locally.
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const deadlineMs = alert ? new Date(alert.docReviewEndsAt).getTime() : 0;
|
||||
const [remaining, setRemaining] = useState(() => deadlineMs - Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!deadlineMs) return;
|
||||
const tick = () => setRemaining(deadlineMs - Date.now());
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [deadlineMs]);
|
||||
|
||||
if (!alert) return null;
|
||||
// Half the review phase has to be gone before staff are alarmed — a 30-minute
|
||||
// review warns with 15 minutes left.
|
||||
const halfMs = (Math.max(alert.docReviewMinutes, 1) * 60_000) / 2;
|
||||
if (remaining <= 0 || remaining > halfMs) return null;
|
||||
|
||||
const requestLabel = alert.pendingCount === 1 ? "request" : "requests";
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
withArrow
|
||||
openDelay={200}
|
||||
multiline
|
||||
w={260}
|
||||
label={`Document review ends in ${formatRemaining(remaining)}. ${alert.pendingCount} ${alert.tradeDirection.toLowerCase()} booking ${requestLabel} ${alert.pendingCount === 1 ? "is" : "are"} still neither accepted nor rejected and will expire automatically. Click to review them.`}
|
||||
>
|
||||
<UnstyledButton
|
||||
onClick={() => navigate(pendingRequestsHref(alert.tradeDirection))}
|
||||
aria-label={`${alert.pendingCount} import booking ${requestLabel} awaiting a decision — document review ends in ${formatRemaining(remaining)}`}
|
||||
className="group flex h-9 shrink-0 items-center gap-2 rounded-full border border-red-600/60 bg-red-600 pl-2.5 pr-2 text-white shadow-[0_2px_10px_rgba(220,38,38,0.35)] transition-transform hover:scale-[1.02] hover:bg-red-700"
|
||||
>
|
||||
{/* Live dot: a ping ring behind a solid core, so the pill reads as
|
||||
active without animating the whole chip. */}
|
||||
<span className="relative flex size-2 shrink-0">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-white opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-white" />
|
||||
</span>
|
||||
|
||||
<AlertTriangle size={15} strokeWidth={2.2} className="shrink-0" />
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
visibleFrom="sm"
|
||||
className="whitespace-nowrap text-white!"
|
||||
>
|
||||
{alert.pendingCount} undecided
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
size="xs"
|
||||
fw={700}
|
||||
className="text-white!"
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "0.02em" }}
|
||||
>
|
||||
{formatRemaining(remaining)}
|
||||
</Text>
|
||||
|
||||
<ChevronRight
|
||||
size={14}
|
||||
strokeWidth={2.2}
|
||||
className="shrink-0 opacity-80 transition-transform group-hover:translate-x-0.5"
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,13 @@ export const useCargoLeafOptions = (enabled = true) =>
|
||||
const code = String(row.code ?? "").trim();
|
||||
const label =
|
||||
name && code ? `${name} (${code})` : name || code || String(row.id);
|
||||
return { label, value: String(row.id) };
|
||||
// The commodity's unit of measure rides along so the rate form can
|
||||
// offer per-item units for counted (break-bulk) commodities.
|
||||
return {
|
||||
label,
|
||||
value: String(row.id),
|
||||
unitOfMeasure: String(row.unitOfMeasure ?? ""),
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ export const FREIGHT_PERMS = {
|
||||
reviewDocuments: "edr_freight_app:bookings:review_documents",
|
||||
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
|
||||
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
|
||||
docReviewAlert: "edr_freight_app:bookings:doc_review_alert",
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
@@ -45,6 +46,8 @@ export const FREIGHT_PERMS = {
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
hazardousApprovalOne: "edr_freight_app:contracts:hazardous_approval_one",
|
||||
hazardousApprovalTwo: "edr_freight_app:contracts:hazardous_approval_two",
|
||||
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||
signStaff: {
|
||||
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
||||
@@ -441,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* The two hazardous-goods steps prepended to a hazardous contract's chain.
|
||||
* They are not position types — they authorize purely on their own permission,
|
||||
* exactly as the API's HAZARDOUS_APPROVAL_ROLE_PERMISSION does.
|
||||
*/
|
||||
export const HAZARDOUS_APPROVAL_ROLE_PERMISSION: Record<string, string> = {
|
||||
HAZARDOUS_APPROVAL_ONE: FREIGHT_PERMS.contracts.hazardousApprovalOne,
|
||||
HAZARDOUS_APPROVAL_TWO: FREIGHT_PERMS.contracts.hazardousApprovalTwo,
|
||||
};
|
||||
|
||||
/** Display label for an approval step's role (hazardous steps get real names). */
|
||||
export const CONTRACT_APPROVAL_ROLE_LABELS: Record<string, string> = {
|
||||
HAZARDOUS_APPROVAL_ONE: "Hazardous review — first approver",
|
||||
HAZARDOUS_APPROVAL_TWO: "Hazardous review — second approver",
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this user action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
@@ -463,6 +482,10 @@ export function canApproveContractStep(
|
||||
if (!user || !requiredRole) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
// Hazardous steps are permission-only — no position type stands in for them.
|
||||
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||
if (hazardousPermission) return hasPermission(user, hazardousPermission);
|
||||
|
||||
const positionTypes = getPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
@@ -477,6 +500,17 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sees the header countdown warning that document review is about to end with
|
||||
* requests still undecided. Its own permission — granted per position type, so
|
||||
* only the desks that act on those requests get alarmed.
|
||||
*/
|
||||
export function canSeeDocReviewAlert(
|
||||
user: AuthUser | null | undefined,
|
||||
): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.docReviewAlert);
|
||||
}
|
||||
|
||||
export function canAccessContracts(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.contracts.view);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ import {
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
|
||||
@@ -123,14 +123,25 @@ function formatDate(value: string | null | undefined): string {
|
||||
|
||||
export default function BookingRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
// Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) —
|
||||
// the header's document-review alarm opens exactly the undecided requests it
|
||||
// is counting down for. Read once as the initial state so staff can then
|
||||
// change the filters like any other visit.
|
||||
const [searchParams] = useSearchParams();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// Booking kind is a filter now — one list holds both kinds (null = "all").
|
||||
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const paramStatuses = searchParams.get("statuses") ?? "";
|
||||
const paramDirection = searchParams.get("tradeDirection");
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
|
||||
paramStatuses.split(",").filter(Boolean),
|
||||
);
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(
|
||||
paramDirection,
|
||||
);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [paymentStatusFilter, setPaymentStatusFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
@@ -150,6 +161,15 @@ export default function BookingRequestsPage() {
|
||||
}, 400);
|
||||
}, []);
|
||||
|
||||
// Follow the URL when a deep link arrives while the page is already open
|
||||
// (clicking the header alarm from this very list). Same-value writes are
|
||||
// dropped so a manual filter change is never undone.
|
||||
useEffect(() => {
|
||||
const next = paramStatuses.split(",").filter(Boolean);
|
||||
setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next));
|
||||
setDirectionFilter(paramDirection);
|
||||
}, [paramStatuses, paramDirection]);
|
||||
|
||||
const filter: BookingListFilter = useMemo(() => {
|
||||
return {
|
||||
page: pagination.pageIndex + 1,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
isDjiboutiGl,
|
||||
} from "@/lib/permissions";
|
||||
|
||||
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
@@ -137,10 +138,15 @@ export default function ContractClearanceDetailPage() {
|
||||
// The GL-created booking expired unpaid — the slot is free again and GL
|
||||
// rebooks on the customer's behalf (customs bookings are never self-booked).
|
||||
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
|
||||
const canRebook =
|
||||
bookingExpired &&
|
||||
// Operations sent the GL-created booking back. GL owns customs bookings, so
|
||||
// the note and the resubmit belong here, not in the customer's portal.
|
||||
const bookingNeedsChanges =
|
||||
clearance?.linkedBookingStatus === "OPERATION_CHANGES_REQUESTED";
|
||||
const isGlBookingOwner =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
|
||||
const canRebook = bookingExpired && isGlBookingOwner;
|
||||
const rebookHref = linkedBookingId
|
||||
? `${bookingHref}?copyFrom=${linkedBookingId}`
|
||||
: bookingHref;
|
||||
@@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() {
|
||||
) : null}
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : bookingNeedsChanges && linkedBookingId ? (
|
||||
<BookingChangesRequestedAlert
|
||||
bookingId={linkedBookingId}
|
||||
reference={clearance.linkedBookingReference}
|
||||
note={clearance.linkedBookingReviewNote}
|
||||
scheduledDate={clearance.linkedBookingScheduledDate}
|
||||
canResubmit={canResubmitBooking}
|
||||
onResubmitted={() => {
|
||||
void refetch();
|
||||
void refetchContract();
|
||||
refetchBookingMilestonesIfLinked();
|
||||
}}
|
||||
/>
|
||||
) : bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
|
||||
@@ -135,9 +135,10 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
return {
|
||||
id: contract.id,
|
||||
reference: contract.reference,
|
||||
// The queue joins the company relation — show its name, never the raw uuid.
|
||||
customerLabel: contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—"),
|
||||
: (contract.company?.name ?? "—"),
|
||||
tradeDirection: contract.tradeDirection ?? "—",
|
||||
freightType: contract.freightType ?? "—",
|
||||
originLabel: yardLabel(first?.originYard),
|
||||
|
||||
@@ -50,6 +50,7 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import {
|
||||
@@ -587,6 +588,11 @@ export default function ContractRequestDetailPage() {
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{contract.isHazardous ? (
|
||||
<Box mb="md">
|
||||
<HazardDeclarationPanel contract={contract} />
|
||||
</Box>
|
||||
) : null}
|
||||
{(contract.cargoScope ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No cargo scope lines.
|
||||
|
||||
@@ -18,7 +18,9 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
@@ -38,6 +40,7 @@ export default function ContractViewPage() {
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
@@ -56,6 +59,7 @@ export default function ContractViewPage() {
|
||||
signatureImageBase64: usingSaved
|
||||
? (savedSignatureImage as string)
|
||||
: (signatureData ?? ""),
|
||||
stampImageBase64: stampData ?? "",
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I confirm this contract on behalf of EDR.",
|
||||
}),
|
||||
@@ -66,7 +70,12 @@ export default function ContractViewPage() {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
||||
},
|
||||
onError: () => toast.error("Failed to sign contract"),
|
||||
// Surface the server's reason verbatim — the missing-customer-stamp gate
|
||||
// and the status guards all explain themselves in the message.
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
extractApiError(err).message ?? "Failed to sign contract",
|
||||
),
|
||||
});
|
||||
|
||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
||||
@@ -89,12 +98,13 @@ export default function ContractViewPage() {
|
||||
const openSign = () => {
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setStampData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim()) return;
|
||||
if (!signerName.trim() || !stampData) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate();
|
||||
@@ -231,6 +241,14 @@ export default function ContractViewPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
label="EDR company stamp"
|
||||
description="Attach the official EDR stamp or seal — it is applied to the contract next to the signature."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
@@ -241,7 +259,8 @@ export default function ContractViewPage() {
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
|
||||
@@ -1180,22 +1180,16 @@ export function LocomotivesCrudPage() {
|
||||
// of the weight tolerance.
|
||||
{ key: 'overageToleranceTons', label: 'Weight tolerance (tons over max pull)', type: 'number' },
|
||||
{ key: 'overageToleranceMeters', label: 'Length tolerance (meters over max length)', type: 'number' },
|
||||
{ key: 'powerKw', label: 'Power (kW)', type: 'number' },
|
||||
{ key: 'tractionForceKn', label: 'Traction force (kN)', type: 'number' },
|
||||
{ key: 'maxSpeedKmh', label: 'Max speed (km/h)', type: 'number' },
|
||||
]}
|
||||
emptyValues={{
|
||||
code: '',
|
||||
name: '',
|
||||
locomotiveType: 'DIESEL',
|
||||
status: 'AVAILABLE',
|
||||
maxPullWeightTons: 0,
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
overageToleranceTons: '',
|
||||
overageToleranceMeters: '',
|
||||
powerKw: '',
|
||||
tractionForceKn: '',
|
||||
maxSpeedKmh: '',
|
||||
overageToleranceTons: 93,
|
||||
overageToleranceMeters: 10,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -233,22 +233,16 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
// of the weight tolerance.
|
||||
{ name: "overageToleranceTons", label: "Weight tolerance (tons over max pull)", type: "number" },
|
||||
{ name: "overageToleranceMeters", label: "Length tolerance (meters over max length)", type: "number" },
|
||||
{ name: "powerKw", label: "Power (kW)", type: "number" },
|
||||
{ name: "tractionForceKn", label: "Traction force (kN)", type: "number" },
|
||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||
],
|
||||
emptyValues: {
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 2500,
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
overageToleranceTons: "",
|
||||
overageToleranceMeters: "",
|
||||
powerKw: "",
|
||||
tractionForceKn: "",
|
||||
maxSpeedKmh: "",
|
||||
overageToleranceTons: 93,
|
||||
overageToleranceMeters: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
RULE_ENGINE_CATEGORY_BASE_PATH,
|
||||
RULE_ENGINE_SELECT_NONE,
|
||||
getRuleEngineResource,
|
||||
rateUnitOptions,
|
||||
type RuleEngineNavCategory,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
|
||||
@@ -344,6 +345,20 @@ const RuleEngineResourcePage = () => {
|
||||
options: cargoLeafOptions ?? [],
|
||||
};
|
||||
}
|
||||
// Rate units follow the picked commodity: a per-item (break-bulk) cargo
|
||||
// is priced per item where a weighed one is priced per ton.
|
||||
if (field.name === "rateUnit" && field.optionsFromValues) {
|
||||
return {
|
||||
...field,
|
||||
optionsFromValues: (values: Record<string, unknown>) =>
|
||||
rateUnitOptions(
|
||||
values,
|
||||
(cargoLeafOptions ?? []).find(
|
||||
(o) => o.value === String(values.cargoTypeId ?? ""),
|
||||
)?.unitOfMeasure ?? "",
|
||||
),
|
||||
};
|
||||
}
|
||||
if (field.name === "rateId") {
|
||||
return {
|
||||
...field,
|
||||
|
||||
@@ -207,13 +207,27 @@ const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value
|
||||
/**
|
||||
* Valid weighting units for a rate shape — mirrors the API's
|
||||
* `allowedRateUnits`. The unit is driven by the *type* being billed: containers
|
||||
* bill per container, bulk per ton, overweight always per excess ton, etc. Kept
|
||||
* in sync with apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||
* bill per container, bulk per ton, overweight always per excess ton, etc. A
|
||||
* rate scoped to a break-bulk commodity (unit of measure = PER_ITEM) offers
|
||||
* PER_ITEM wherever a weighed one offers PER_TON. Kept in sync with
|
||||
* apps/edr-freight-api/.../entities/rate-unit.util.ts.
|
||||
*/
|
||||
const allowedRateUnits = (
|
||||
appliesTo: string,
|
||||
trigger: string,
|
||||
cargoKind = "",
|
||||
cargoUnitOfMeasure = "",
|
||||
): string[] => {
|
||||
const units = unitsForShape(appliesTo, trigger, cargoKind);
|
||||
return cargoUnitOfMeasure === "PER_ITEM"
|
||||
? units.map((u) => (u === "PER_TON" ? "PER_ITEM" : u))
|
||||
: units;
|
||||
};
|
||||
|
||||
const unitsForShape = (
|
||||
appliesTo: string,
|
||||
trigger: string,
|
||||
cargoKind = "",
|
||||
): string[] => {
|
||||
if (appliesTo === "OTHER") {
|
||||
switch (trigger) {
|
||||
@@ -259,7 +273,15 @@ const allowedRateUnits = (
|
||||
}
|
||||
};
|
||||
|
||||
const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||
/**
|
||||
* Unit choices for the rate form. `cargoUnitOfMeasure` is how the bulk
|
||||
* commodity picked in the form is counted (PER_TON / PER_ITEM) — injected by
|
||||
* RuleEngineResourcePage, which is the layer that has the cargo type list.
|
||||
*/
|
||||
export const rateUnitOptions = (
|
||||
values: Record<string, unknown>,
|
||||
cargoUnitOfMeasure = "",
|
||||
) => {
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
@@ -267,6 +289,7 @@ const rateUnitOptions = (values: Record<string, unknown>) => {
|
||||
appliesTo,
|
||||
trigger,
|
||||
String(values.cargoKind ?? ""),
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
};
|
||||
|
||||
@@ -903,7 +926,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
optionsFromValues: rateUnitOptions,
|
||||
description: "Weighting basis — options depend on what the rate applies to.",
|
||||
description:
|
||||
"Weighting basis — options depend on what the rate applies to, and for bulk on how the picked commodity is counted (per ton or per item).",
|
||||
hideWhen: { field: "trigger", equals: ["OVERWEIGHT"] },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
EligibleContainerBookingsResponse,
|
||||
FreightType,
|
||||
ImportLoadingBookingsResponse,
|
||||
DocReviewAlert,
|
||||
LoadingStatus,
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
@@ -276,6 +277,13 @@ export const api = {
|
||||
() => ["train-scheduling", "all-booking-windows"],
|
||||
),
|
||||
|
||||
docReviewAlert: endpoint<void, DocReviewAlert | null>(
|
||||
"train-scheduling",
|
||||
"doc-review-alert",
|
||||
() => trainSchedulingService.getDocReviewAlert(),
|
||||
() => ["train-scheduling", "doc-review-alert"],
|
||||
),
|
||||
|
||||
batchBoardDetail: endpoint<
|
||||
{ scheduleId: string },
|
||||
BatchBoardScheduleDetail
|
||||
@@ -1660,15 +1668,6 @@ export const api = {
|
||||
() => [["wagons"]],
|
||||
),
|
||||
|
||||
reorder: endpoint<{ trainId: string; wagonIds: string[] }, Wagon[]>(
|
||||
"wagons",
|
||||
"reorder",
|
||||
({ trainId, wagonIds }) =>
|
||||
wagonService.reorder(trainId, wagonIds).then((r) => r.data),
|
||||
undefined,
|
||||
() => [["wagons"]],
|
||||
),
|
||||
|
||||
create: endpoint<Partial<Wagon>, Wagon>(
|
||||
"wagons",
|
||||
"create",
|
||||
@@ -2534,6 +2533,13 @@ export const api = {
|
||||
bookingsService.reviewOperation(id, decision, { note }),
|
||||
),
|
||||
|
||||
proceedToOperation: endpoint<
|
||||
{ id: string; scheduledDate: string },
|
||||
BookingDetail
|
||||
>("bookings", "proceedToOperation", ({ id, scheduledDate }) =>
|
||||
bookingsService.proceedToOperation(id, scheduledDate),
|
||||
),
|
||||
|
||||
generateContract: endpoint<{ id: string }, BookingDetail>(
|
||||
"bookings",
|
||||
"generateContract",
|
||||
|
||||
@@ -243,6 +243,14 @@ export const bookingsService = {
|
||||
...options,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Re-request operation on a booking Operations sent back for changes. The
|
||||
* customer path uses the same endpoint from the portal; GL needs it here
|
||||
* because a customs booking is GL's to fix, not the customer's.
|
||||
*/
|
||||
proceedToOperation: (id: string, scheduledDate: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_PROCEED(id), { scheduledDate }),
|
||||
|
||||
generateContract: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ export interface ContractView {
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
stampImageUrl?: string | null;
|
||||
}>;
|
||||
savedSignature?: {
|
||||
signerDisplayName: string;
|
||||
@@ -119,6 +120,8 @@ export interface ContractView {
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
/** Company stamp/seal image; required to sign a contract. */
|
||||
stampImageBase64?: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
}
|
||||
|
||||
@@ -35,9 +35,6 @@ export interface Locomotive {
|
||||
overageToleranceTons?: number | null;
|
||||
/** Metres a train may exceed maxTrainLengthMeters by before scheduling blocks it. */
|
||||
overageToleranceMeters?: number | null;
|
||||
powerKw?: number | null;
|
||||
tractionForceKn?: number | null;
|
||||
maxSpeedKmh?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
BookingLoadResult,
|
||||
BookingUnloadResult,
|
||||
CompositionRemovalEntry,
|
||||
DocReviewAlert,
|
||||
UnassignedBookingsResponse,
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
@@ -709,6 +710,15 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getDocReviewAlert: async (): Promise<DocReviewAlert | null> => {
|
||||
const response = await client.get<DocReviewAlert | null>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.DOC_REVIEW_ALERT,
|
||||
);
|
||||
// "No alert" comes back as null — which Nest sends as an empty body, so
|
||||
// coerce anything falsy to null (react-query rejects undefined).
|
||||
return unwrap(response.data) || null;
|
||||
},
|
||||
|
||||
updateGlobalRules: async (
|
||||
payload: Partial<Omit<TrainSchedulingGlobalRules, "id">>,
|
||||
): Promise<TrainSchedulingGlobalRules> => {
|
||||
|
||||
@@ -88,8 +88,6 @@ export const wagonService = {
|
||||
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
|
||||
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),
|
||||
unassign: (wagonId: string) => apiClient.post(`/wagons/${wagonId}/unassign-train`),
|
||||
reorder: (trainId: string, wagonIds: string[]) =>
|
||||
apiClient.post(`/trains/${trainId}/reorder-wagons`, { wagonIds }),
|
||||
create: (data: Partial<Wagon>) => apiClient.post('/wagons', data),
|
||||
update: (id: string, data: Partial<Wagon>) => apiClient.patch(`/wagons/${id}`, data),
|
||||
delete: (id: string) => apiClient.delete(`/wagons/${id}`),
|
||||
|
||||
@@ -287,6 +287,25 @@ export interface BatchBoardBooking {
|
||||
* An announced booking window on any lane (import cycle or export FCFS), for
|
||||
* staff dashboards. Mirrors the customer portal's MyBookingWindow.
|
||||
*/
|
||||
/**
|
||||
* The nearest document-review deadline that still has booking requests nobody
|
||||
* accepted or rejected. Everything still pending when it lapses is expired by
|
||||
* the window engine, so the header counts down to it.
|
||||
*/
|
||||
export interface DocReviewAlert {
|
||||
scheduleId: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT booking day, YYYY-MM-DD. */
|
||||
day: string;
|
||||
/** IMPORT (the usual) or DOMESTIC — the direction the at-risk requests belong to. */
|
||||
tradeDirection: string;
|
||||
docReviewEndsAt: string;
|
||||
/** Full length of the review phase — warn past its halfway mark. */
|
||||
docReviewMinutes: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
export interface StaffBookingWindow {
|
||||
scheduleId: string;
|
||||
reference: string | null;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
|
||||
import { RefreshCw, Stamp, X } from "lucide-react";
|
||||
|
||||
const MAX_STAMP_MB = 5;
|
||||
|
||||
export interface StampUploadProps {
|
||||
/** Stamp image as a data URL, or null when none is attached yet. */
|
||||
value: string | null;
|
||||
onChange: (dataUrl: string | null) => void;
|
||||
label?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Company stamp/seal attachment for the contract signing modal. Reads the
|
||||
* picked image straight into a data URL because the signing endpoint takes
|
||||
* base64 in JSON (same transport as the drawn signature), not multipart.
|
||||
*/
|
||||
export function StampUpload({
|
||||
value,
|
||||
onChange,
|
||||
label = "Company stamp",
|
||||
description = "Attach your official company stamp or seal.",
|
||||
}: StampUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
|
||||
const readFile = (file: File | undefined | null) => {
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith("image/")) {
|
||||
setError("The stamp must be an image file (PNG or JPG).");
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_STAMP_MB * 1024 * 1024) {
|
||||
setError(`The stamp image must be under ${MAX_STAMP_MB} MB.`);
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
setError(null);
|
||||
setFileName(file.name);
|
||||
onChange(typeof reader.result === "string" ? reader.result : null);
|
||||
};
|
||||
reader.onerror = () => setError("Could not read that file. Try another.");
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const openPicker = () => inputRef.current?.click();
|
||||
|
||||
const clear = () => {
|
||||
setFileName(null);
|
||||
setError(null);
|
||||
onChange(null);
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={6}>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
hidden
|
||||
onChange={(e) => readFile(e.currentTarget.files?.[0])}
|
||||
/>
|
||||
|
||||
{value ? (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group gap="md" wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--mantine-color-gray-1) 0% 25%, transparent 0% 50%) 50% / 14px 14px",
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
padding: 6,
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={value}
|
||||
alt="Company stamp"
|
||||
fit="contain"
|
||||
h={92}
|
||||
w={92}
|
||||
/>
|
||||
</Box>
|
||||
<Stack gap={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{fileName ?? "Stamp attached"}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
This stamp is applied next to your signature on the contract.
|
||||
</Text>
|
||||
<Group gap="xs" mt={2}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<RefreshCw size={13} />}
|
||||
onClick={openPicker}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<X size={13} />}
|
||||
onClick={clear}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : (
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="lg"
|
||||
onClick={openPicker}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
readFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
style={{
|
||||
borderColor: dragging
|
||||
? "var(--mantine-color-edr-green-6)"
|
||||
: undefined,
|
||||
borderStyle: "dashed",
|
||||
backgroundColor: dragging
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: undefined,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Stack gap={6} align="center">
|
||||
<Stamp size={26} color="var(--mantine-color-edr-green-6)" />
|
||||
<Text size="sm" fw={500}>
|
||||
Upload company stamp
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{description} Drop an image here or click to browse — PNG or JPG,
|
||||
up to {MAX_STAMP_MB} MB.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="xs" c="red.7">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -923,7 +923,7 @@ export default function ContractDetailPage() {
|
||||
</Text>
|
||||
)}
|
||||
{item.isClearance && (
|
||||
<Text fz={12} c="orange.7" fw={600}>
|
||||
<Text fz={12} fw={600} style={{ color: GREEN }}>
|
||||
Customs service fee — billed on your shipment booking
|
||||
invoice together with the freight
|
||||
</Text>
|
||||
|
||||
@@ -15,7 +15,14 @@ import {
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui";
|
||||
import {
|
||||
BORDER,
|
||||
GREEN,
|
||||
GREEN_DARK,
|
||||
INK,
|
||||
MUTED,
|
||||
expiryNoticeDays,
|
||||
} from "./contract-ui";
|
||||
|
||||
/**
|
||||
* The customer-facing contract journey, in order. This is the *contract track*
|
||||
@@ -124,15 +131,6 @@ function resolveStep(status: string): StepState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Days until the contract validity lapses, if any (negative = already lapsed). */
|
||||
function daysUntil(dateIso?: string | null): number | null {
|
||||
if (!dateIso) return null;
|
||||
const end = new Date(dateIso).getTime();
|
||||
if (Number.isNaN(end)) return null;
|
||||
const ms = end - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
export interface ContractStepBannerProps {
|
||||
contract: Freight.IContract;
|
||||
}
|
||||
@@ -145,9 +143,9 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
|
||||
const { activeIdx, terminal, next } = resolveStep(contract.status);
|
||||
const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED";
|
||||
|
||||
const expiryDays = daysUntil(contract.contractValidUntil);
|
||||
const expirySoon =
|
||||
!terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14;
|
||||
// Same notice window as the list badge and the API's reminder.
|
||||
const expiryDays = expiryNoticeDays(contract);
|
||||
const expirySoon = !terminal && expiryDays !== null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -28,6 +28,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { StampUpload } from "@/components/contracts/StampUpload";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
@@ -52,6 +53,7 @@ export default function ContractViewPage() {
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [stampData, setStampData] = useState<string | null>(null);
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
const [hasScrolledToBottom, setHasScrolledToBottom] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
@@ -138,6 +140,7 @@ export default function ContractViewPage() {
|
||||
signatureImageBase64: usingSaved
|
||||
? (savedSignatureImage as string)
|
||||
: (signatureData as string),
|
||||
stampImageBase64: stampData as string,
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: CONSENT_TEXT,
|
||||
otp: otpCode.trim(),
|
||||
@@ -161,6 +164,7 @@ export default function ContractViewPage() {
|
||||
if (!canProceedToSign) return;
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setStampData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
@@ -168,7 +172,7 @@ export default function ContractViewPage() {
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim()) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
if (!image || !stampData) return;
|
||||
// The server resolves and validates the signer's own contacts; if the
|
||||
// account has neither phone nor email it returns a clear 400 that surfaces
|
||||
// via the mutation's onError.
|
||||
@@ -248,6 +252,15 @@ export default function ContractViewPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Signed before company stamps were required — re-signing is the only
|
||||
way to attach one, and EDR cannot counter-sign until it is there. */}
|
||||
{data.canSignCustomer && data.status === "SIGNED_CUSTOMER" && (
|
||||
<Alert color="orange" variant="light" radius="md" mb="md">
|
||||
This contract was signed before a company stamp was required. Please
|
||||
sign again and attach your stamp so EDR can counter-sign it.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{data.canSignCustomer && !hasScrolledToBottom && (
|
||||
<Alert color="blue" variant="light" radius="md" mb="md">
|
||||
Please scroll through the entire contract before signing.
|
||||
@@ -360,6 +373,13 @@ export default function ContractViewPage() {
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
|
||||
<StampUpload
|
||||
value={stampData}
|
||||
onChange={setStampData}
|
||||
description="Attach your official company stamp or seal — it is applied to the contract next to your signature."
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
@@ -370,7 +390,8 @@ export default function ContractViewPage() {
|
||||
disabled={
|
||||
sendOtpMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
(!usingSaved && !signatureData) ||
|
||||
!stampData
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
|
||||
@@ -43,6 +43,7 @@ import { usePagination } from "@edr/ui-common";
|
||||
import {
|
||||
BORDER,
|
||||
ContractDocButton,
|
||||
ContractExpiryBadge,
|
||||
ContractStatusBadge,
|
||||
GREEN,
|
||||
INK,
|
||||
@@ -600,6 +601,9 @@ export default function ContractsList() {
|
||||
).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
{/* Countdown once the contract is inside the notice
|
||||
window — renders nothing before that. */}
|
||||
<ContractExpiryBadge contract={c} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ContractStatusBadge status={c.status} />
|
||||
|
||||
@@ -70,7 +70,11 @@ import {
|
||||
Step4Route,
|
||||
Step8Review,
|
||||
} from "./new-contract-form/steps";
|
||||
import { StepCard, StepHeader } from "./new-contract-form/shared";
|
||||
import {
|
||||
NotFinalPriceNotice,
|
||||
StepCard,
|
||||
StepHeader,
|
||||
} from "./new-contract-form/shared";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
|
||||
type PriceModalMode = "submit" | "draft";
|
||||
@@ -225,6 +229,19 @@ export default function NewContractPage({
|
||||
isAxiosError(persistAndPriceMutation.error) &&
|
||||
persistAndPriceMutation.error.response?.status === 422;
|
||||
|
||||
// 409 from create = an active contract already covers this service type +
|
||||
// route for this customer — surfaced as a blocking modal, same as above.
|
||||
const duplicateContract =
|
||||
isAxiosError(persistAndPriceMutation.error) &&
|
||||
persistAndPriceMutation.error.response?.status === 409;
|
||||
// The API bakes the existing contract's reference + valid-until date into
|
||||
// this message (global exception filter drops any other response fields).
|
||||
const duplicateContractMessage =
|
||||
(isAxiosError(persistAndPriceMutation.error) &&
|
||||
(persistAndPriceMutation.error.response?.data as { message?: string })
|
||||
?.message) ||
|
||||
"An active contract already exists for this service type and route.";
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!priceContractId) throw new Error("No contract to confirm");
|
||||
@@ -606,6 +623,11 @@ export default function NewContractPage({
|
||||
}
|
||||
: {}),
|
||||
isHazardous: data.isHazardous,
|
||||
// The dangerous-goods declaration only travels with the flag — the API
|
||||
// rejects a hazardous contract that omits either field.
|
||||
...(data.isHazardous
|
||||
? { hazardClass: data.hazardClass, unNumber: data.unNumber }
|
||||
: {}),
|
||||
// Reefer is a contract-level flag for both container and bulk.
|
||||
isReefer: data.isRefrigerated,
|
||||
...(data.previousContractRef
|
||||
@@ -766,7 +788,9 @@ export default function NewContractPage({
|
||||
<StepIndicator step={step} steps={visibleSteps} />
|
||||
</Box>
|
||||
|
||||
{persistAndPriceMutation.isError && !rateNotConfigured && (
|
||||
{persistAndPriceMutation.isError &&
|
||||
!rateNotConfigured &&
|
||||
!duplicateContract && (
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<AlertCircle size={16} />}
|
||||
@@ -806,6 +830,24 @@ export default function NewContractPage({
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Duplicate contract (409) — same customer already has a non-expired
|
||||
contract for this service type + route. Block with a modal. */}
|
||||
<Modal
|
||||
opened={duplicateContract}
|
||||
onClose={() => persistAndPriceMutation.reset()}
|
||||
title="Contract creation unavailable"
|
||||
centered
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">{duplicateContractMessage}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={() => persistAndPriceMutation.reset()}>
|
||||
OK
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{/* Step 0 — Setup: operation, contract, service, currency, miles. */}
|
||||
{step === 0 && (
|
||||
<StepCard>
|
||||
@@ -964,15 +1006,14 @@ export default function NewContractPage({
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
mb="xs"
|
||||
mb="md"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Pricing schedule
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Final amount is calculated at booking — quantities are unknown
|
||||
at the contract stage.
|
||||
</Text>
|
||||
<Box mb="md">
|
||||
<NotFinalPriceNotice />
|
||||
</Box>
|
||||
<Stack gap={10}>
|
||||
{pricingData.lineItems.map((item) => (
|
||||
<Group
|
||||
@@ -992,7 +1033,7 @@ export default function NewContractPage({
|
||||
</Text>
|
||||
)}
|
||||
{item.isClearance && (
|
||||
<Text size="xs" c="orange.7" fw={600}>
|
||||
<Text size="xs" c="edr-green" fw={600}>
|
||||
Customs service fee — billed on your shipment booking
|
||||
invoice together with the freight
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { EXPIRY_NOTICE_DAYS, expiryNoticeDays, expiryNoticeLabel } from "./contract-ui";
|
||||
|
||||
const inDays = (days: number): string =>
|
||||
// Half a day past the boundary so ceil() lands on `days` regardless of the
|
||||
// clock at test time.
|
||||
new Date(Date.now() + (days - 0.5) * 86_400_000).toISOString();
|
||||
|
||||
const contract = (over: Record<string, unknown> = {}) =>
|
||||
({
|
||||
status: "CONTRACT_ACTIVE",
|
||||
contractValidUntil: inDays(5),
|
||||
...over,
|
||||
}) as never;
|
||||
|
||||
describe("expiryNoticeDays", () => {
|
||||
it("counts the days left once inside the notice window", () => {
|
||||
expect(expiryNoticeDays(contract({ contractValidUntil: inDays(5) }))).toBe(5);
|
||||
expect(
|
||||
expiryNoticeDays(
|
||||
contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS) }),
|
||||
),
|
||||
).toBe(EXPIRY_NOTICE_DAYS);
|
||||
});
|
||||
|
||||
it("stays silent while the contract is further out than the window", () => {
|
||||
expect(
|
||||
expiryNoticeDays(
|
||||
contract({ contractValidUntil: inDays(EXPIRY_NOTICE_DAYS + 1) }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("stays silent for a contract with no validity date", () => {
|
||||
expect(expiryNoticeDays(contract({ contractValidUntil: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it("stays silent once the date has passed — that is expiry, not a warning", () => {
|
||||
expect(expiryNoticeDays(contract({ contractValidUntil: inDays(-1) }))).toBeNull();
|
||||
});
|
||||
|
||||
it("stays silent on contracts that are already over", () => {
|
||||
for (const status of ["EXPIRED", "CANCELLED", "REJECTED", "CONTRACT_CLOSED"]) {
|
||||
expect(expiryNoticeDays(contract({ status }))).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("expiryNoticeLabel", () => {
|
||||
it("reads naturally at the edges", () => {
|
||||
expect(expiryNoticeLabel(0)).toBe("Expires today");
|
||||
expect(expiryNoticeLabel(1)).toBe("1 day left");
|
||||
expect(expiryNoticeLabel(10)).toBe("10 days left");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Group, Paper, Text, Tooltip } from "@mantine/core";
|
||||
import { FileText } from "lucide-react";
|
||||
import { AlertTriangle, FileText } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -209,6 +209,88 @@ export function ContractStatusBadge({ status }: { status: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* How close to its validity end a contract has to be before the customer is
|
||||
* warned. The API notifies at the same distance (contract-expiry.service), so
|
||||
* the inbox message and the list badge agree.
|
||||
*/
|
||||
export const EXPIRY_NOTICE_DAYS = 10;
|
||||
|
||||
/** Whole days until a date (0 = today, negative = already past). Null if unset. */
|
||||
export function daysUntil(dateIso?: string | null): number | null {
|
||||
if (!dateIso) return null;
|
||||
const end = new Date(dateIso).getTime();
|
||||
if (Number.isNaN(end)) return null;
|
||||
return Math.ceil((end - Date.now()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** Contracts that are already over — no point warning about their expiry. */
|
||||
const CLOSED_CONTRACT_STATUSES = [
|
||||
"REJECTED",
|
||||
"CANCELLED",
|
||||
"CONTRACT_CLOSED",
|
||||
"ARCHIVED",
|
||||
"EXPIRED",
|
||||
];
|
||||
|
||||
/**
|
||||
* Days left on a live contract, but only inside the notice window — null when
|
||||
* the contract is closed, has no validity date, has already lapsed, or is still
|
||||
* further out than {@link EXPIRY_NOTICE_DAYS}.
|
||||
*/
|
||||
export function expiryNoticeDays(
|
||||
contract: Pick<Freight.IContract, "status" | "contractValidUntil">,
|
||||
): number | null {
|
||||
if (CLOSED_CONTRACT_STATUSES.includes(contract.status)) return null;
|
||||
const days = daysUntil(contract.contractValidUntil);
|
||||
if (days == null || days < 0 || days > EXPIRY_NOTICE_DAYS) return null;
|
||||
return days;
|
||||
}
|
||||
|
||||
/** "Expires today" / "5 days left" — the wording shared by list and banner. */
|
||||
export function expiryNoticeLabel(days: number): string {
|
||||
if (days === 0) return "Expires today";
|
||||
return `${days} day${days === 1 ? "" : "s"} left`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amber countdown pill shown on a contract that is about to lapse. Renders
|
||||
* nothing outside the notice window, so callers can drop it in unconditionally.
|
||||
*/
|
||||
export function ContractExpiryBadge({
|
||||
contract,
|
||||
}: {
|
||||
contract: Pick<Freight.IContract, "status" | "contractValidUntil">;
|
||||
}) {
|
||||
const days = expiryNoticeDays(contract);
|
||||
if (days == null) return null;
|
||||
return (
|
||||
<Tooltip
|
||||
withArrow
|
||||
label="This contract stops accepting bookings when its validity ends. Contact EDR to renew it."
|
||||
>
|
||||
<Group
|
||||
gap={5}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
mt={4}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#FEF6E7",
|
||||
border: "1px solid #F5D9A3",
|
||||
padding: "2px 8px",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={12} color="#9A6700" />
|
||||
<Text fz={11} fw={700} style={{ color: "#9A6700", whiteSpace: "nowrap" }}>
|
||||
{expiryNoticeLabel(days)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** A labelled value used across the contract detail summary cards. */
|
||||
export function MetaItem({
|
||||
label,
|
||||
|
||||
@@ -122,6 +122,8 @@ export function contractToFormValues(
|
||||
bulkQuantityCap:
|
||||
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
|
||||
isHazardous: contract.isHazardous,
|
||||
hazardClass: contract.hazardClass ?? "",
|
||||
unNumber: contract.unNumber ?? "",
|
||||
isRefrigerated: contract.isReefer,
|
||||
|
||||
originYard: primaryRoute?.originYardId ?? "",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DeepPartial, Path } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { HAZARD_CLASS_VALUES } from "@edr/types";
|
||||
|
||||
// Wizard steps for the contract creation flow. Condensed to four steps: the
|
||||
// pickers are dropdown selects so each step fits one screen without scrolling.
|
||||
@@ -177,6 +178,10 @@ export const contractFormSchema = z
|
||||
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
||||
// Contract-level billing flags.
|
||||
isHazardous: z.boolean().default(false),
|
||||
// Dangerous-goods declaration — collected with the hazard documents and
|
||||
// mandatory whenever isHazardous (enforced in the superRefine below).
|
||||
hazardClass: z.string().default(""),
|
||||
unNumber: z.string().default(""),
|
||||
isRefrigerated: z.boolean().default(false),
|
||||
|
||||
// ── Route ── (one route per contract — general contracts included)
|
||||
@@ -241,6 +246,24 @@ export const contractFormSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
// Hazardous cargo must name its UN/ADR class and UN number — the API
|
||||
// rejects the contract otherwise, so catch it before the wizard submits.
|
||||
if (data.isHazardous) {
|
||||
if (!HAZARD_CLASS_VALUES.includes(data.hazardClass)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["hazardClass"],
|
||||
message: "Select the dangerous-goods class.",
|
||||
});
|
||||
}
|
||||
if (!data.unNumber.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["unNumber"],
|
||||
message: "Enter the UN number.",
|
||||
});
|
||||
}
|
||||
}
|
||||
// GENERAL contracts are uncapped: no quantity cap is collected, so the
|
||||
// customer can book repeatedly until the contract's validity expires. The
|
||||
// cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at
|
||||
@@ -271,6 +294,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
cargoFreeText: "",
|
||||
bulkQuantityCap: 0,
|
||||
isHazardous: false,
|
||||
hazardClass: "",
|
||||
unNumber: "",
|
||||
isRefrigerated: false,
|
||||
|
||||
originYard: "",
|
||||
@@ -307,6 +332,8 @@ export const contractStepFields: Record<
|
||||
"cargoFreeText",
|
||||
"bulkQuantityCap",
|
||||
"isHazardous",
|
||||
"hazardClass",
|
||||
"unNumber",
|
||||
"isRefrigerated",
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { Loader } from "lucide-react";
|
||||
import { Loader, Receipt } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
@@ -84,6 +88,55 @@ export function SelectField<
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prominent "these are unit rates, not your bill" banner. Shown wherever the
|
||||
* customer is looking at contract pricing (review step + quotation modal) —
|
||||
* the contract quotes per-unit rates only; the payable total is computed at
|
||||
* booking from the quantities actually shipped.
|
||||
*/
|
||||
export function NotFinalPriceNotice() {
|
||||
return (
|
||||
<Group
|
||||
gap={13}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
px={16}
|
||||
py={14}
|
||||
style={{
|
||||
borderRadius: 14,
|
||||
border: "1.5px solid #F2DFB4",
|
||||
background: "linear-gradient(135deg, #FFF9EC 0%, #FFFDF8 75%)",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FBEED0",
|
||||
color: "#A9741A",
|
||||
}}
|
||||
>
|
||||
<Receipt size={18} />
|
||||
</Box>
|
||||
<Stack gap={3} style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="#10202F">
|
||||
This is not your final price
|
||||
</Text>
|
||||
<Text fz={12.5} c="#6B7C8E" style={{ lineHeight: 1.5 }}>
|
||||
The figures below are <strong>per-unit rates</strong> for each service
|
||||
— not a total. Your payable amount is calculated on every booking from
|
||||
the quantities you actually ship, and invoiced then.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface AsyncComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
// Snowflake — restore with the Refrigerated Cargo switch below.
|
||||
import { Container, Flame, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -13,10 +14,11 @@ import {
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { HAZARD_CLASSES, hazardClassLabel, type Freight } from "@edr/types";
|
||||
import { api } from "@/services/api";
|
||||
import {
|
||||
CONTAINER_SIZES,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { operationToTradeDirection } from "./helpers";
|
||||
import { fieldStyles, SelectField, StepLabel } from "./shared";
|
||||
import { AlertBox, fieldStyles, SelectField, StepLabel } from "./shared";
|
||||
|
||||
/**
|
||||
* file_upload_settings code holding the hazardous-cargo document requirements.
|
||||
@@ -95,6 +97,18 @@ export function Step3CargoScope({
|
||||
const [hazardModalOpen, setHazardModalOpen] = useState(false);
|
||||
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
|
||||
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
|
||||
// Dangerous-goods declaration, edited in the modal and only written back to
|
||||
// the form once the customer confirms — cancelling must leave the contract
|
||||
// exactly as it was.
|
||||
const [classDraft, setClassDraft] = useState<string | null>(null);
|
||||
const [unDraft, setUnDraft] = useState("");
|
||||
const [declErrors, setDeclErrors] = useState<{
|
||||
hazardClass?: string;
|
||||
unNumber?: string;
|
||||
}>({});
|
||||
|
||||
const hazardClass = form.watch("hazardClass");
|
||||
const unNumber = form.watch("unNumber");
|
||||
|
||||
/** Drop every hazardous document from the contract's document map. */
|
||||
const clearHazardDocs = () => {
|
||||
@@ -117,6 +131,9 @@ export function Step3CargoScope({
|
||||
),
|
||||
);
|
||||
setHazardErrors({});
|
||||
setClassDraft(form.getValues("hazardClass") || null);
|
||||
setUnDraft(form.getValues("unNumber") ?? "");
|
||||
setDeclErrors({});
|
||||
setHazardModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -124,14 +141,20 @@ export function Step3CargoScope({
|
||||
const missing = hazardFields.filter(
|
||||
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
setHazardErrors(
|
||||
Object.fromEntries(
|
||||
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
|
||||
),
|
||||
);
|
||||
return;
|
||||
const nextDeclErrors: typeof declErrors = {};
|
||||
if (!classDraft) {
|
||||
nextDeclErrors.hazardClass = "Select the dangerous-goods class.";
|
||||
}
|
||||
if (!unDraft.trim()) nextDeclErrors.unNumber = "Enter the UN number.";
|
||||
|
||||
setHazardErrors(
|
||||
Object.fromEntries(
|
||||
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
|
||||
),
|
||||
);
|
||||
setDeclErrors(nextDeclErrors);
|
||||
if (missing.length > 0 || Object.keys(nextDeclErrors).length > 0) return;
|
||||
|
||||
form.setValue(
|
||||
"documents",
|
||||
{
|
||||
@@ -140,16 +163,29 @@ export function Step3CargoScope({
|
||||
},
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
form.setValue("hazardClass", classDraft!, { shouldDirty: true });
|
||||
form.setValue("unNumber", unDraft.trim().toUpperCase(), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
form.clearErrors(["hazardClass", "unNumber"]);
|
||||
form.setValue("isHazardous", true, { shouldDirty: true });
|
||||
setHazardModalOpen(false);
|
||||
};
|
||||
|
||||
/** Turning the switch off drops the declaration with the documents. */
|
||||
const clearHazardDeclaration = () => {
|
||||
form.setValue("hazardClass", "", { shouldDirty: true });
|
||||
form.setValue("unNumber", "", { shouldDirty: true });
|
||||
form.clearErrors(["hazardClass", "unNumber"]);
|
||||
};
|
||||
|
||||
// A hidden flag must never leak into the payload: a general contract can't be
|
||||
// hazardous, and a non-import contract carries neither reefer nor empty return.
|
||||
useEffect(() => {
|
||||
if (isOneTime) return;
|
||||
if (form.getValues("isHazardous")) {
|
||||
form.setValue("isHazardous", false, { shouldDirty: true });
|
||||
clearHazardDeclaration();
|
||||
}
|
||||
// Runs again once the hazard field list loads — a no-op when nothing matches.
|
||||
clearHazardDocs();
|
||||
@@ -356,25 +392,42 @@ export function Step3CargoScope({
|
||||
name="isHazardous"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
openHazardModal();
|
||||
return;
|
||||
}
|
||||
field.onChange(false);
|
||||
clearHazardDocs();
|
||||
}}
|
||||
/>
|
||||
<Stack gap={0}>
|
||||
<ToggleRow
|
||||
icon={<Flame size={18} />}
|
||||
iconBg="#FBEAE7"
|
||||
iconColor="#C0392B"
|
||||
title="Hazardous Material"
|
||||
description="Applies a hazard surcharge as a per-container unit rate. Requires a UN class, UN number and hazard documents."
|
||||
checked={field.value ?? false}
|
||||
onChange={(v) => {
|
||||
if (v) {
|
||||
openHazardModal();
|
||||
return;
|
||||
}
|
||||
field.onChange(false);
|
||||
clearHazardDocs();
|
||||
clearHazardDeclaration();
|
||||
}}
|
||||
/>
|
||||
{field.value && (
|
||||
<HazardDeclarationSummary
|
||||
hazardClass={hazardClass}
|
||||
unNumber={unNumber}
|
||||
onEdit={openHazardModal}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!isOneTime && (
|
||||
<AlertBox tone="info">
|
||||
Hazardous material can only be declared on a one-time contract.
|
||||
Switch <strong>Contract Kind</strong> to one-time to carry
|
||||
hazardous cargo.
|
||||
</AlertBox>
|
||||
)}
|
||||
{/* Refrigerated cargo is hidden for now — import-only when re-enabled.
|
||||
The effect above keeps isRefrigerated false while it's off.
|
||||
{isImport && (
|
||||
@@ -422,17 +475,83 @@ export function Step3CargoScope({
|
||||
<Modal
|
||||
opened={hazardModalOpen}
|
||||
onClose={() => setHazardModalOpen(false)}
|
||||
title="Hazardous cargo documents"
|
||||
title={
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 10,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#FBEAE7",
|
||||
color: "#C0392B",
|
||||
}}
|
||||
>
|
||||
<Flame size={16} />
|
||||
</Box>
|
||||
<Text fw={700} fz={15} c="#10202F">
|
||||
Hazardous cargo declaration
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size="lg"
|
||||
centered
|
||||
radius={14}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<Stack gap={18}>
|
||||
<Text fz={13} c="#6B7C8E">
|
||||
Hazardous cargo can only move once the documents below are attached
|
||||
to the contract.
|
||||
Dangerous goods move only once the class and UN number are declared
|
||||
and the documents below are attached to the contract. EDR reviews
|
||||
this declaration in two dedicated hazardous approval steps.
|
||||
</Text>
|
||||
|
||||
<Stack gap={12}>
|
||||
<StepLabel>Dangerous-goods declaration</StepLabel>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="UN / ADR class *"
|
||||
placeholder="Select the hazard class…"
|
||||
data={HAZARD_CLASSES.map((c) => ({
|
||||
value: c.value,
|
||||
label: c.label,
|
||||
}))}
|
||||
value={classDraft}
|
||||
onChange={(v) => {
|
||||
setClassDraft(v);
|
||||
setDeclErrors((e) => ({ ...e, hazardClass: undefined }));
|
||||
}}
|
||||
error={declErrors.hazardClass}
|
||||
allowDeselect={false}
|
||||
radius={10}
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{
|
||||
withinPortal: true,
|
||||
shadow: "md",
|
||||
radius: "md",
|
||||
}}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
<TextInput
|
||||
label="UN number *"
|
||||
placeholder="e.g. 1203"
|
||||
description="The four-digit UN number of the dangerous good."
|
||||
value={unDraft}
|
||||
onChange={(e) => {
|
||||
setUnDraft(e.currentTarget.value);
|
||||
setDeclErrors((err) => ({ ...err, unNumber: undefined }));
|
||||
}}
|
||||
error={declErrors.unNumber}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<StepLabel>Hazard documents</StepLabel>
|
||||
|
||||
{hazardSettingQuery.isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" color="edr-green" />
|
||||
@@ -461,7 +580,7 @@ export function Step3CargoScope({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
|
||||
Save & mark hazardous
|
||||
Save declaration
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -470,6 +589,59 @@ export function Step3CargoScope({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads back the saved dangerous-goods declaration under the hazard switch, so
|
||||
* the customer can see (and fix) the class + UN number without reopening the
|
||||
* modal blind. Rendered only while the contract is flagged hazardous.
|
||||
*/
|
||||
function HazardDeclarationSummary({
|
||||
hazardClass,
|
||||
unNumber,
|
||||
onEdit,
|
||||
}: {
|
||||
hazardClass?: string;
|
||||
unNumber?: string;
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const label = hazardClassLabel(hazardClass);
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="sm"
|
||||
px={16}
|
||||
py={11}
|
||||
mt={-1}
|
||||
style={{
|
||||
borderRadius: "0 0 14px 14px",
|
||||
border: "1.5px solid #F3D5D0",
|
||||
borderTop: "none",
|
||||
background: "#FEF7F6",
|
||||
}}
|
||||
>
|
||||
<Group gap={8} align="center" wrap="wrap" style={{ minWidth: 0 }}>
|
||||
<Badge color="red" variant="light" radius="sm" size="sm">
|
||||
{label ?? "Class not set"}
|
||||
</Badge>
|
||||
<Badge color="red" variant="light" radius="sm" size="sm">
|
||||
{unNumber ? `UN ${unNumber}` : "UN number not set"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="compact-sm"
|
||||
radius={8}
|
||||
onClick={onEdit}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
icon,
|
||||
iconBg,
|
||||
|
||||
@@ -22,12 +22,12 @@ import {
|
||||
Send,
|
||||
Truck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { hazardClassLabel, type Freight } from "@edr/types";
|
||||
import {
|
||||
type ContractFormInputValues,
|
||||
type ContractFormValues,
|
||||
} from "./schema";
|
||||
import { StepHeader } from "./shared";
|
||||
import { NotFinalPriceNotice, StepHeader } from "./shared";
|
||||
import { formatRateUnit } from "./unit-rates";
|
||||
|
||||
type ContractForm = UseFormReturn<
|
||||
@@ -125,15 +125,14 @@ function UnitRatePanel({
|
||||
fw={700}
|
||||
tt="uppercase"
|
||||
c="edr-green"
|
||||
mb="xs"
|
||||
mb="md"
|
||||
style={{ letterSpacing: "0.06em" }}
|
||||
>
|
||||
Pricing schedule
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="md">
|
||||
Estimated unit rates — the final amount is calculated at booking from the
|
||||
quantities you ship.
|
||||
</Text>
|
||||
<Box mb="md">
|
||||
<NotFinalPriceNotice />
|
||||
</Box>
|
||||
<Stack gap={10}>
|
||||
{lineItems.map((item) => (
|
||||
<Group
|
||||
@@ -231,30 +230,28 @@ export function Step8Review({
|
||||
|
||||
// Mirror the step-2 gating: imports never truck the first mile, exports never
|
||||
// truck the last mile, and a service that doesn't bundle a mile can't have it.
|
||||
// A mile the customer didn't take is left off the summary entirely rather than
|
||||
// shown as an empty "not applicable" row.
|
||||
const firstMileValue =
|
||||
direction === "IMPORT"
|
||||
? "Not applicable for import"
|
||||
: serviceType && !serviceType.includesFirstMile
|
||||
? "Not included in service"
|
||||
: values.firstMile.enabled
|
||||
? `${values.firstMile.pickUpAddress || "Pinned"}${
|
||||
values.firstMile.exactLocation
|
||||
? ` · ${values.firstMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: "Not requested";
|
||||
direction !== "IMPORT" &&
|
||||
(!serviceType || serviceType.includesFirstMile) &&
|
||||
values.firstMile.enabled
|
||||
? `${values.firstMile.pickUpAddress || "Pinned"}${
|
||||
values.firstMile.exactLocation
|
||||
? ` · ${values.firstMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: null;
|
||||
const lastMileValue =
|
||||
direction === "EXPORT"
|
||||
? "Not applicable for export"
|
||||
: serviceType && !serviceType.includesLastMile
|
||||
? "Not included in service"
|
||||
: values.lastMile.enabled
|
||||
? `${values.lastMile.deliveryAddress || "Pinned"}${
|
||||
values.lastMile.exactLocation
|
||||
? ` · ${values.lastMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: "Not requested";
|
||||
direction !== "EXPORT" &&
|
||||
(!serviceType || serviceType.includesLastMile) &&
|
||||
values.lastMile.enabled
|
||||
? `${values.lastMile.deliveryAddress || "Pinned"}${
|
||||
values.lastMile.exactLocation
|
||||
? ` · ${values.lastMile.exactLocation}`
|
||||
: ""
|
||||
}`
|
||||
: null;
|
||||
|
||||
const cargoValue = (() => {
|
||||
if (values.cargoType === "container") {
|
||||
@@ -399,16 +396,20 @@ export function Step8Review({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Truck size={18} />}
|
||||
label="First mile — pick-up"
|
||||
value={firstMileValue}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Truck size={18} />}
|
||||
label="Last mile — delivery"
|
||||
value={lastMileValue}
|
||||
/>
|
||||
{firstMileValue && (
|
||||
<SummaryItem
|
||||
icon={<Truck size={18} />}
|
||||
label="First mile — pick-up"
|
||||
value={firstMileValue}
|
||||
/>
|
||||
)}
|
||||
{lastMileValue && (
|
||||
<SummaryItem
|
||||
icon={<Truck size={18} />}
|
||||
label="Last mile — delivery"
|
||||
value={lastMileValue}
|
||||
/>
|
||||
)}
|
||||
<SummaryItem
|
||||
icon={<FileText size={18} />}
|
||||
label="Customs clearing"
|
||||
@@ -417,7 +418,23 @@ export function Step8Review({
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
label="Hazardous cargo"
|
||||
value={values.isHazardous ? "Yes" : "No"}
|
||||
value={
|
||||
values.isHazardous ? (
|
||||
<>
|
||||
Yes
|
||||
<Group gap={6} mt={6}>
|
||||
<Badge size="sm" variant="light" color="red" radius="sm">
|
||||
{hazardClassLabel(values.hazardClass) ?? "Class —"}
|
||||
</Badge>
|
||||
<Badge size="sm" variant="light" color="red" radius="sm">
|
||||
UN {values.unNumber || "—"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
"No"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
|
||||
@@ -154,11 +154,12 @@ export function computeShipmentTotal(
|
||||
}
|
||||
|
||||
// Lashing / cargo securing — bulk-only, applies whenever the contract shows
|
||||
// it (the commodity needs lashing). Per-ton scales by tonnage; per-wagon
|
||||
// depends on the wagon capacity the train stocks — shown at real pricing.
|
||||
// it (the commodity needs lashing). Per-ton/per-item scales by the cargo
|
||||
// amount; per-wagon depends on the wagon capacity the train stocks — shown at
|
||||
// real pricing.
|
||||
const lashing = items.find((i) => i.conditionalOn === "has_lashing");
|
||||
if (lashing && lashing.unit === "per_ton") {
|
||||
const tons = Number(values.cargoWeightTons || 0);
|
||||
if (lashing && (lashing.unit === "per_ton" || lashing.unit === "per_item")) {
|
||||
const tons = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
if (tons > 0) {
|
||||
lines.push({
|
||||
label: lashing.label,
|
||||
@@ -184,8 +185,8 @@ export function computeShipmentTotal(
|
||||
cl.unit === "per_wagon"
|
||||
? Math.ceil(boxes * (cl.containerSize === "40ft" ? 1 : 0.5))
|
||||
: boxes;
|
||||
} else if (cl.unit === "per_ton") {
|
||||
qty = Number(values.cargoWeightTons || 0);
|
||||
} else if (cl.unit === "per_ton" || cl.unit === "per_item") {
|
||||
qty = Number(values.cargoWeightTons || values.itemCount || 0);
|
||||
} else if (cl.unit === "flat") {
|
||||
qty = 1;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface ContractView {
|
||||
signerDisplayName: string;
|
||||
signedAt: string;
|
||||
signatureImageUrl?: string | null;
|
||||
stampImageUrl?: string | null;
|
||||
}>;
|
||||
/** Current viewer's reusable saved signature, if they have one. */
|
||||
savedSignature?: {
|
||||
@@ -124,6 +125,8 @@ export interface SubmitBookingResponse {
|
||||
export interface SignContractPayload {
|
||||
role: "CUSTOMER" | "STAFF";
|
||||
signatureImageBase64: string;
|
||||
/** Company stamp/seal image; required to sign a contract (not booking contracts). */
|
||||
stampImageBase64?: string;
|
||||
signerDisplayName: string;
|
||||
consentText?: string;
|
||||
/** Sudo-mode OTP challenge; required when role=CUSTOMER. */
|
||||
|
||||
@@ -20,6 +20,38 @@ export enum ContractFreightType {
|
||||
Bulk = "BULK",
|
||||
}
|
||||
|
||||
/**
|
||||
* The nine UN/ADR dangerous-goods classes. A hazardous contract must declare
|
||||
* exactly one class plus the shipment's UN number — both are captured with the
|
||||
* hazard documents in the portal and reviewed by the two hazardous approvers in
|
||||
* the backoffice.
|
||||
*/
|
||||
export const HAZARD_CLASSES = [
|
||||
{ value: "CLASS_1", label: "Class 1: Explosives" },
|
||||
{ value: "CLASS_2", label: "Class 2: Gases" },
|
||||
{ value: "CLASS_3", label: "Class 3: Flammable Liquids" },
|
||||
{ value: "CLASS_4", label: "Class 4: Flammable Solids" },
|
||||
{ value: "CLASS_5", label: "Class 5: Oxidizers and Organic Peroxides" },
|
||||
{ value: "CLASS_6", label: "Class 6: Toxic and Infectious Substances" },
|
||||
{ value: "CLASS_7", label: "Class 7: Radioactive Materials" },
|
||||
{ value: "CLASS_8", label: "Class 8: Corrosive Substances" },
|
||||
{ value: "CLASS_9", label: "Class 9: Miscellaneous Dangerous Goods" },
|
||||
] as const;
|
||||
|
||||
export type HazardClass = (typeof HAZARD_CLASSES)[number]["value"];
|
||||
|
||||
export const HAZARD_CLASS_VALUES: readonly string[] = HAZARD_CLASSES.map(
|
||||
(c) => c.value,
|
||||
);
|
||||
|
||||
/** Human label for a stored hazard class code; falls back to the raw code. */
|
||||
export function hazardClassLabel(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
return HAZARD_CLASSES.find((c) => c.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
/** Who created a shipment booking under a contract. */
|
||||
export type BookingCreatedByRole = "CUSTOMER" | "GL_ET" | "STAFF";
|
||||
|
||||
@@ -184,6 +216,8 @@ export interface IContractSignature {
|
||||
role: ContractSignatureRole;
|
||||
signerDisplayName: string;
|
||||
signatureFileId?: string | null;
|
||||
/** Company stamp/seal image, required for the CUSTOMER and STAFF parties. */
|
||||
stampFileId?: string | null;
|
||||
consentText?: string | null;
|
||||
signedAt: string;
|
||||
}
|
||||
@@ -426,6 +460,10 @@ export interface ContractClearanceView {
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
/** Operations' latest "needs changes" note on that booking — GL acts on it. */
|
||||
linkedBookingReviewNote?: string | null;
|
||||
/** Shipment day the booking holds; the default when GL resubmits it. */
|
||||
linkedBookingScheduledDate?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -659,6 +697,10 @@ export interface IContract extends BaseEntity {
|
||||
lastMileDeliveryLng?: number | null;
|
||||
|
||||
isHazardous: boolean;
|
||||
/** UN/ADR dangerous-goods class — set only when `isHazardous`. */
|
||||
hazardClass?: HazardClass | string | null;
|
||||
/** UN number of the dangerous good — set only when `isHazardous`. */
|
||||
unNumber?: string | null;
|
||||
isReefer: boolean;
|
||||
|
||||
estimatedShipmentDate?: string | null;
|
||||
@@ -786,6 +828,10 @@ export interface CreateContractDto {
|
||||
lastMileDeliveryLng?: number;
|
||||
|
||||
isHazardous?: boolean;
|
||||
/** Required when `isHazardous` — one of HAZARD_CLASSES. */
|
||||
hazardClass?: string;
|
||||
/** Required when `isHazardous` — the shipment's UN number. */
|
||||
unNumber?: string;
|
||||
isReefer?: boolean;
|
||||
|
||||
estimatedShipmentDate?: string;
|
||||
|
||||
Reference in New Issue
Block a user