mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: add hazardous goods declaration feature
- Introduced HazardDeclarationPanel component to display dangerous goods declaration details. - Updated URL constants to include CLEARANCE_PROCEED endpoint for re-requesting operations. - Enhanced permissions to include hazardous approval roles for contract approvals. - Integrated HazardDeclarationPanel into ContractRequestDetailPage and ContractClearanceDetailPage. - Added proceedToOperation method in bookings service for handling operation re-requests. - Updated contract forms and schemas to include hazard class and UN number fields. - Implemented validation for hazardous contracts in the contract creation flow. - Added expiry notice functionality for contracts nearing validity end. - Created tests for expiry notice calculations and labels. - Updated UI components to reflect hazardous cargo information and validation errors.
This commit is contained in:
@@ -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,
|
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> = {
|
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||||
@@ -183,6 +201,16 @@ export function assertCanApproveContractStep(
|
|||||||
): void {
|
): void {
|
||||||
if (isFreightApprovalAdmin(user)) return;
|
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);
|
const positionTypes = collectPositionTypeKeys(user);
|
||||||
if (positionTypes.includes(requiredRole)) return;
|
if (positionTypes.includes(requiredRole)) return;
|
||||||
|
|
||||||
@@ -219,6 +247,11 @@ export function canEditContractStep(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (isFreightApprovalAdmin(user)) return true;
|
if (isFreightApprovalAdmin(user)) return true;
|
||||||
|
|
||||||
|
const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole];
|
||||||
|
if (hazardousPermission) {
|
||||||
|
return hasFreightPermission(user, hazardousPermission);
|
||||||
|
}
|
||||||
|
|
||||||
const positionTypes = collectPositionTypeKeys(user);
|
const positionTypes = collectPositionTypeKeys(user);
|
||||||
if (positionTypes.includes(requiredRole)) return true;
|
if (positionTypes.includes(requiredRole)) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { hazardClassLabel } from '@edr/types';
|
||||||
|
|
||||||
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
import { ContractsRepository } from '../modules/contracts/contracts.repository';
|
||||||
import {
|
import {
|
||||||
@@ -143,6 +144,11 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
|
|
||||||
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER');
|
||||||
const hasStaff = signatures.some((s) => s.role === 'STAFF');
|
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(
|
const hasContractFile = Boolean(
|
||||||
contract.files?.some((f) => f.code === 'contract'),
|
contract.files?.some((f) => f.code === 'contract'),
|
||||||
);
|
);
|
||||||
@@ -185,7 +191,9 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
// Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking
|
||||||
// view-model's narrower CUSTOMER|STAFF role union.
|
// view-model's narrower CUSTOMER|STAFF role union.
|
||||||
signatures: signatures as unknown as ContractViewModel['signatures'],
|
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:
|
canSignStaff:
|
||||||
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff,
|
||||||
hasContractDocument: hasContractFile,
|
hasContractDocument: hasContractFile,
|
||||||
@@ -295,7 +303,16 @@ export class ContractDocumentViewModelBuilder {
|
|||||||
cargoDescription: this.valueOrDash(cargoName),
|
cargoDescription: this.valueOrDash(cargoName),
|
||||||
totalWeightVgm: '—',
|
totalWeightVgm: '—',
|
||||||
equipmentReturn: this.valueOrDash(contract.equipmentReturn),
|
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),
|
firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress),
|
||||||
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
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 =
|
const msg =
|
||||||
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
`Your operation request for booking ${b.reference} needs changes: ${note}. ` +
|
||||||
`Please update and resubmit from the portal.`;
|
`Please update and resubmit from the portal.`;
|
||||||
|
|||||||
@@ -33,41 +33,86 @@ import {
|
|||||||
BookingReferenceYardDto,
|
BookingReferenceYardDto,
|
||||||
} from "./dto/booking-reference-data.dto";
|
} 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(
|
export function buildCargoTypeTree(
|
||||||
rows: CargoType[],
|
rows: CargoType[],
|
||||||
): BookingReferenceCargoTypeGroupDto[] {
|
): BookingReferenceCargoTypeGroupDto[] {
|
||||||
const active = rows.filter((r) => r.isActive);
|
const active = rows.filter((r) => r.isActive);
|
||||||
const parents = active
|
|
||||||
.filter((r) => !r.parentGroupId)
|
const byOrder = (a: CargoType, b: CargoType) =>
|
||||||
.sort(
|
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code);
|
||||||
(a, b) => 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) => {
|
return parents.map((parent) => {
|
||||||
const children = active
|
const kids = childrenOf.get(parent.id) ?? [];
|
||||||
.filter((r) => r.parentGroupId === parent.id)
|
const children =
|
||||||
.sort(
|
kids.length === 0
|
||||||
(a, b) =>
|
? // The group itself is the commodity.
|
||||||
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
|
[
|
||||||
)
|
{
|
||||||
.map(
|
id: parent.id,
|
||||||
(child): BookingReferenceCargoTypeChildDto => ({
|
name: parent.cargoTypeName,
|
||||||
id: child.id,
|
code: parent.code,
|
||||||
name: child.cargoTypeName,
|
unit_of_measure: parent.unitOfMeasure ?? null,
|
||||||
code: child.code,
|
},
|
||||||
unit_of_measure: child.unitOfMeasure ?? null,
|
]
|
||||||
}),
|
: kids.flatMap((kid) => collectLeaves(kid, [], new Set<string>()));
|
||||||
);
|
|
||||||
|
|
||||||
const group: BookingReferenceCargoTypeGroupDto = {
|
return {
|
||||||
id: parent.id,
|
id: parent.id,
|
||||||
name: parent.cargoTypeName,
|
name: parent.cargoTypeName,
|
||||||
code: parent.code,
|
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. */
|
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||||
linkedBookingReference?: string | null;
|
linkedBookingReference?: string | null;
|
||||||
linkedBookingStatus?: 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?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -301,11 +309,24 @@ export class ContractClearanceService {
|
|||||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||||
let linkedBookingReference: string | null = null;
|
let linkedBookingReference: string | null = null;
|
||||||
let linkedBookingStatus: string | null = null;
|
let linkedBookingStatus: string | null = null;
|
||||||
|
let linkedBookingReviewNote: string | null = null;
|
||||||
|
let linkedBookingScheduledDate: string | null = null;
|
||||||
if (cycle?.bookingId) {
|
if (cycle?.bookingId) {
|
||||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||||
if (booking) {
|
if (booking) {
|
||||||
linkedBookingReference = booking.reference ?? null;
|
linkedBookingReference = booking.reference ?? null;
|
||||||
linkedBookingStatus = booking.status ?? 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') {
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
nextAction = this.workflowService.computeNextActionForBooking(
|
nextAction = this.workflowService.computeNextActionForBooking(
|
||||||
booking,
|
booking,
|
||||||
@@ -349,6 +370,8 @@ export class ContractClearanceService {
|
|||||||
linkedBookingId: cycle?.bookingId ?? null,
|
linkedBookingId: cycle?.bookingId ?? null,
|
||||||
linkedBookingReference,
|
linkedBookingReference,
|
||||||
linkedBookingStatus,
|
linkedBookingStatus,
|
||||||
|
linkedBookingReviewNote,
|
||||||
|
linkedBookingScheduledDate,
|
||||||
dutyAdvice,
|
dutyAdvice,
|
||||||
workflowFiles,
|
workflowFiles,
|
||||||
t1,
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,13 @@ import { NotificationAudience, NotificationType } from '@edr/types';
|
|||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { ContractsRepository } from './contracts.repository';
|
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. */
|
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ContractExpiryService {
|
export class ContractExpiryService {
|
||||||
@@ -15,6 +22,51 @@ export class ContractExpiryService {
|
|||||||
private readonly inbox: NotificationInboxService,
|
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' })
|
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
|
||||||
async expireLapsedContracts(): Promise<void> {
|
async expireLapsedContracts(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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,
|
assertCanApproveContractStep,
|
||||||
assertFreightPermission,
|
assertFreightPermission,
|
||||||
canEditContractStep,
|
canEditContractStep,
|
||||||
|
HAZARDOUS_APPROVAL_ROLES,
|
||||||
} from '../../common/freight-permission.util';
|
} from '../../common/freight-permission.util';
|
||||||
import {
|
import {
|
||||||
FREIGHT_PERMS,
|
FREIGHT_PERMS,
|
||||||
@@ -530,12 +531,26 @@ export class ContractTransitionService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rule of chain) {
|
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
|
||||||
await this.contractsRepository.createApprovalStep({
|
// chain — if either refuses, the contract never reaches the approvers who
|
||||||
contractId: contract.id,
|
// would price and sign it. Steps are renumbered sequentially so the prefix
|
||||||
stepOrder: rule.stepOrder,
|
// 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,
|
requiredRole: rule.requiredRole,
|
||||||
blocksRole: rule.blocksRole ?? null,
|
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',
|
status: 'PENDING',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1111,7 +1126,17 @@ export class ContractTransitionService {
|
|||||||
options.signerUserId,
|
options.signerUserId,
|
||||||
contract,
|
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);
|
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||||
await this.otpService.sendOtp(signerContacts);
|
await this.otpService.sendOtp(signerContacts);
|
||||||
@@ -1135,9 +1160,16 @@ export class ContractTransitionService {
|
|||||||
options.signerUserId,
|
options.signerUserId,
|
||||||
contract,
|
contract,
|
||||||
);
|
);
|
||||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
|
||||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
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');
|
throw new BadRequestException('Customer has already signed this contract');
|
||||||
}
|
}
|
||||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||||
|
|||||||
@@ -107,6 +107,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
return result.affected ?? 0;
|
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. */
|
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||||
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
|
|||||||
@@ -343,6 +343,10 @@ export class ContractsService {
|
|||||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||||
isHazardous: dto.isHazardous ?? false,
|
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,
|
isReefer: dto.isReefer ?? false,
|
||||||
contractType: dto.contractType ?? null,
|
contractType: dto.contractType ?? null,
|
||||||
status: 'DRAFT',
|
status: 'DRAFT',
|
||||||
@@ -515,6 +519,13 @@ export class ContractsService {
|
|||||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||||
isReefer: dto.isReefer ?? existing.isReefer,
|
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,
|
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { HAZARD_CLASS_VALUES } from '@edr/types';
|
||||||
|
|
||||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||||
|
|
||||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||||
@@ -228,6 +230,28 @@ export class CreateContractDto {
|
|||||||
@Transform(({ value }) => value === 'true' || value === true)
|
@Transform(({ value }) => value === 'true' || value === true)
|
||||||
isHazardous?: boolean;
|
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' })
|
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
@@ -188,6 +188,14 @@ export class Contract extends BaseEntity {
|
|||||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||||
isHazardous!: boolean;
|
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 })
|
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||||
isReefer!: boolean;
|
isReefer!: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { TrainScheduleStatus } from '@edr/types';
|
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 { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
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 { FilterRoutesDto } from './dto/filter-routes.dto';
|
||||||
import { UpdateRouteDto } from './dto/update-route.dto';
|
import { UpdateRouteDto } from './dto/update-route.dto';
|
||||||
import { RouteMilestone } from './entities/route-milestone.entity';
|
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';
|
import { RoutesRepository } from './routes.repository';
|
||||||
|
|
||||||
/** Order-insensitive key: distances are symmetric. */
|
/** Order-insensitive key: distances are symmetric. */
|
||||||
@@ -88,6 +88,7 @@ export class RoutesService {
|
|||||||
|
|
||||||
async create(dto: CreateRouteDto): Promise<Route> {
|
async create(dto: CreateRouteDto): Promise<Route> {
|
||||||
const validated = await this.validateMilestones(dto.milestones);
|
const validated = await this.validateMilestones(dto.milestones);
|
||||||
|
await this.assertNotDuplicate(validated.milestones);
|
||||||
|
|
||||||
const route = await this.dataSource.transaction(async (manager) => {
|
const route = await this.dataSource.transaction(async (manager) => {
|
||||||
const savedRoute = await manager.getRepository(Route).save(
|
const savedRoute = await manager.getRepository(Route).save(
|
||||||
@@ -123,6 +124,11 @@ export class RoutesService {
|
|||||||
? await this.validateMilestones(dto.milestones)
|
? await this.validateMilestones(dto.milestones)
|
||||||
: null;
|
: 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
|
// Milestones or endpoints are about to be rewritten — reject if any
|
||||||
// non-terminal schedule still references this route, otherwise its stop list
|
// non-terminal schedule still references this route, otherwise its stop list
|
||||||
// and distances would silently shift under a live plan. Status-only /
|
// and distances would silently shift under a live plan. Status-only /
|
||||||
@@ -187,6 +193,51 @@ export class RoutesService {
|
|||||||
return this.findById(id);
|
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 }>) {
|
private async validateMilestones(milestones: Array<{ yardId: string }>) {
|
||||||
if (milestones.length < 2) {
|
if (milestones.length < 2) {
|
||||||
throw new BadRequestException('A route requires at least two yards');
|
throw new BadRequestException('A route requires at least two yards');
|
||||||
|
|||||||
@@ -99,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-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-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'),
|
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
|
// Existing per-slug view ids are kept as-is: position-type grants reference
|
||||||
@@ -423,6 +427,8 @@ export const FREIGHT_PERMS = {
|
|||||||
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
approveLineStaff: 'edr_freight_app:contracts:approve_line_staff',
|
||||||
approveDirector: 'edr_freight_app:contracts:approve_director',
|
approveDirector: 'edr_freight_app:contracts:approve_director',
|
||||||
approveCeo: 'edr_freight_app:contracts:approve_ceo',
|
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',
|
generateContract: 'edr_freight_app:contracts:generate_contract',
|
||||||
signStaff: {
|
signStaff: {
|
||||||
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
|
bulk: 'edr_freight_app:contracts:sign_staff:bulk',
|
||||||
|
|||||||
@@ -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 { useMemo, useState } from "react";
|
||||||
import { Check, ShieldCheck, X } from "lucide-react";
|
import { Check, Flame, ShieldCheck, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
@@ -14,10 +14,22 @@ import {
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||||
|
import { HazardDeclarationPanel } from "./HazardDeclarationPanel";
|
||||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
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>;
|
type Mutations = ReturnType<typeof useContractMutations>;
|
||||||
|
|
||||||
@@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({
|
|||||||
const subtitle =
|
const subtitle =
|
||||||
summary.detail ||
|
summary.detail ||
|
||||||
(nextPending
|
(nextPending
|
||||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}`
|
||||||
: steps.length
|
: steps.length
|
||||||
? "All steps complete"
|
? "All steps complete"
|
||||||
: "Accept submission to begin");
|
: "Accept submission to begin");
|
||||||
@@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
You are about to approve the{" "}
|
You are about to approve the{" "}
|
||||||
<Text span fw={600} c="dark">
|
<Text span fw={600} c="dark">
|
||||||
{pendingStep?.requiredRole}
|
{roleLabel(pendingStep?.requiredRole ?? "")}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
step for contract{" "}
|
step for contract{" "}
|
||||||
<Text span fw={600} c="dark">
|
<Text span fw={600} c="dark">
|
||||||
@@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({
|
|||||||
</Text>
|
</Text>
|
||||||
. This action cannot be undone from this screen.
|
. This action cannot be undone from this screen.
|
||||||
</Text>
|
</Text>
|
||||||
|
{pendingStep && isHazardStep(pendingStep.requiredRole) && (
|
||||||
|
<HazardDeclarationPanel contract={contract} />
|
||||||
|
)}
|
||||||
<Group justify="flex-end" gap="sm">
|
<Group justify="flex-end" gap="sm">
|
||||||
<Button variant="default" radius="md" onClick={closeApprove}>
|
<Button variant="default" radius="md" onClick={closeApprove}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -241,7 +256,7 @@ export function ContractApprovalStepsCard({
|
|||||||
{ value: "CUSTOMER", label: "Customer — must resubmit" },
|
{ value: "CUSTOMER", label: "Customer — must resubmit" },
|
||||||
...returnableSteps.map((s) => ({
|
...returnableSteps.map((s) => ({
|
||||||
value: s.id,
|
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>{" "}
|
</Text>{" "}
|
||||||
will go back to the{" "}
|
will go back to the{" "}
|
||||||
<Text span fw={600} c="dark">
|
<Text span fw={600} c="dark">
|
||||||
{targetStep?.requiredRole}
|
{roleLabel(targetStep?.requiredRole ?? "")}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
step. That approver fixes the contract and approves again, and
|
step. That approver fixes the contract and approves again, and
|
||||||
every later step re-approves in order. The customer is not
|
every later step re-approves in order. The customer is not
|
||||||
@@ -264,7 +279,7 @@ export function ContractApprovalStepsCard({
|
|||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Rejecting the{" "}
|
Rejecting the{" "}
|
||||||
<Text span fw={600} c="dark">
|
<Text span fw={600} c="dark">
|
||||||
{rejectStepRow?.requiredRole}
|
{roleLabel(rejectStepRow?.requiredRole ?? "")}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
step rejects contract{" "}
|
step rejects contract{" "}
|
||||||
<Text span fw={600} c="dark">
|
<Text span fw={600} c="dark">
|
||||||
@@ -300,7 +315,7 @@ export function ContractApprovalStepsCard({
|
|||||||
onClick={runReject}
|
onClick={runReject}
|
||||||
>
|
>
|
||||||
{sendBack
|
{sendBack
|
||||||
? `Send back to ${targetStep?.requiredRole ?? "step"}`
|
? `Send back to ${targetStep ? roleLabel(targetStep.requiredRole) : "step"}`
|
||||||
: "Reject contract"}
|
: "Reject contract"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -333,6 +348,7 @@ function StepRow({
|
|||||||
: isNext
|
: isNext
|
||||||
? "edr-green"
|
? "edr-green"
|
||||||
: "gray";
|
: "gray";
|
||||||
|
const hazard = isHazardStep(step.requiredRole);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
@@ -343,11 +359,19 @@ function StepRow({
|
|||||||
py="xs"
|
py="xs"
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: "1px solid var(--mantine-color-gray-2)",
|
border: hazard
|
||||||
borderLeft: isNext
|
? "1px solid #F3D5D0"
|
||||||
? "3px solid var(--freight-brand)"
|
|
||||||
: "1px solid var(--mantine-color-gray-2)",
|
: "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 }}>
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
@@ -371,9 +395,23 @@ function StepRow({
|
|||||||
{step.stepOrder}
|
{step.stepOrder}
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ minWidth: 0 }}>
|
<Box style={{ minWidth: 0 }}>
|
||||||
<Text size="sm" fw={600}>
|
<Group gap={6} wrap="nowrap" align="center">
|
||||||
{step.requiredRole}
|
<Text size="sm" fw={600} truncate>
|
||||||
</Text>
|
{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 && (
|
{step.note && (
|
||||||
<Text size="xs" c="dimmed" truncate>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{step.note}
|
{step.note}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -156,6 +156,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/bookings/${id}/clearance/ro-amendment`,
|
`/bookings/${id}/clearance/ro-amendment`,
|
||||||
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
CLEARANCE_EXPORT_RELEASE: (id: string) =>
|
||||||
`/bookings/${id}/clearance/export-release`,
|
`/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_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ export const FREIGHT_PERMS = {
|
|||||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
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",
|
generateContract: "edr_freight_app:contracts:generate_contract",
|
||||||
signStaff: {
|
signStaff: {
|
||||||
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
bulk: "edr_freight_app:contracts:sign_staff:bulk",
|
||||||
@@ -442,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
|||||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
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`?
|
* Can this user action a contract approval step requiring `requiredRole`?
|
||||||
*
|
*
|
||||||
@@ -464,6 +482,10 @@ export function canApproveContractStep(
|
|||||||
if (!user || !requiredRole) return false;
|
if (!user || !requiredRole) return false;
|
||||||
if (isFreightApprovalAdmin(user)) return true;
|
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);
|
const positionTypes = getPositionTypeKeys(user);
|
||||||
if (positionTypes.includes(requiredRole)) return true;
|
if (positionTypes.includes(requiredRole)) return true;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
isDjiboutiGl,
|
isDjiboutiGl,
|
||||||
} from "@/lib/permissions";
|
} from "@/lib/permissions";
|
||||||
|
|
||||||
|
import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert";
|
||||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||||
import { PageContainer } from "@/components/page/PageContainer";
|
import { PageContainer } from "@/components/page/PageContainer";
|
||||||
import { PageHeader } from "@/components/page/PageHeader";
|
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
|
// 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).
|
// rebooks on the customer's behalf (customs bookings are never self-booked).
|
||||||
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
|
const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED";
|
||||||
const canRebook =
|
// Operations sent the GL-created booking back. GL owns customs bookings, so
|
||||||
bookingExpired &&
|
// 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) &&
|
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||||
!isDjiboutiGl(user);
|
!isDjiboutiGl(user);
|
||||||
|
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
|
||||||
|
const canRebook = bookingExpired && isGlBookingOwner;
|
||||||
const rebookHref = linkedBookingId
|
const rebookHref = linkedBookingId
|
||||||
? `${bookingHref}?copyFrom=${linkedBookingId}`
|
? `${bookingHref}?copyFrom=${linkedBookingId}`
|
||||||
: bookingHref;
|
: bookingHref;
|
||||||
@@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
) : bookingNeedsChanges && linkedBookingId ? (
|
||||||
|
<BookingChangesRequestedAlert
|
||||||
|
bookingId={linkedBookingId}
|
||||||
|
reference={clearance.linkedBookingReference}
|
||||||
|
note={clearance.linkedBookingReviewNote}
|
||||||
|
scheduledDate={clearance.linkedBookingScheduledDate}
|
||||||
|
canResubmit={canResubmitBooking}
|
||||||
|
onResubmitted={() => {
|
||||||
|
void refetch();
|
||||||
|
void refetchContract();
|
||||||
|
refetchBookingMilestonesIfLinked();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : bookingAlreadyCreated ? (
|
) : bookingAlreadyCreated ? (
|
||||||
<Alert
|
<Alert
|
||||||
color="blue"
|
color="blue"
|
||||||
|
|||||||
@@ -135,9 +135,10 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
|||||||
return {
|
return {
|
||||||
id: contract.id,
|
id: contract.id,
|
||||||
reference: contract.reference,
|
reference: contract.reference,
|
||||||
|
// The queue joins the company relation — show its name, never the raw uuid.
|
||||||
customerLabel: contract.isGovernment
|
customerLabel: contract.isGovernment
|
||||||
? (contract.governmentInstitution ?? "Government")
|
? (contract.governmentInstitution ?? "Government")
|
||||||
: (contract.companyId ?? "—"),
|
: (contract.company?.name ?? "—"),
|
||||||
tradeDirection: contract.tradeDirection ?? "—",
|
tradeDirection: contract.tradeDirection ?? "—",
|
||||||
freightType: contract.freightType ?? "—",
|
freightType: contract.freightType ?? "—",
|
||||||
originLabel: yardLabel(first?.originYard),
|
originLabel: yardLabel(first?.originYard),
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
|
|||||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||||
|
import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel";
|
||||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||||
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||||
import {
|
import {
|
||||||
@@ -587,6 +588,11 @@ export default function ContractRequestDetailPage() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
|
{contract.isHazardous ? (
|
||||||
|
<Box mb="md">
|
||||||
|
<HazardDeclarationPanel contract={contract} />
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
{(contract.cargoScope ?? []).length === 0 ? (
|
{(contract.cargoScope ?? []).length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No cargo scope lines.
|
No cargo scope lines.
|
||||||
|
|||||||
@@ -2533,6 +2533,13 @@ export const api = {
|
|||||||
bookingsService.reviewOperation(id, decision, { note }),
|
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>(
|
generateContract: endpoint<{ id: string }, BookingDetail>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"generateContract",
|
"generateContract",
|
||||||
|
|||||||
@@ -243,6 +243,14 @@ export const bookingsService = {
|
|||||||
...options,
|
...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) =>
|
generateContract: (id: string) =>
|
||||||
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,14 @@ import {
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
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*
|
* 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 {
|
export interface ContractStepBannerProps {
|
||||||
contract: Freight.IContract;
|
contract: Freight.IContract;
|
||||||
}
|
}
|
||||||
@@ -145,9 +143,9 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) {
|
|||||||
const { activeIdx, terminal, next } = resolveStep(contract.status);
|
const { activeIdx, terminal, next } = resolveStep(contract.status);
|
||||||
const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED";
|
const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED";
|
||||||
|
|
||||||
const expiryDays = daysUntil(contract.contractValidUntil);
|
// Same notice window as the list badge and the API's reminder.
|
||||||
const expirySoon =
|
const expiryDays = expiryNoticeDays(contract);
|
||||||
!terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14;
|
const expirySoon = !terminal && expiryDays !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -252,6 +252,15 @@ export default function ContractViewPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</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 && (
|
{data.canSignCustomer && !hasScrolledToBottom && (
|
||||||
<Alert color="blue" variant="light" radius="md" mb="md">
|
<Alert color="blue" variant="light" radius="md" mb="md">
|
||||||
Please scroll through the entire contract before signing.
|
Please scroll through the entire contract before signing.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { usePagination } from "@edr/ui-common";
|
|||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
ContractDocButton,
|
ContractDocButton,
|
||||||
|
ContractExpiryBadge,
|
||||||
ContractStatusBadge,
|
ContractStatusBadge,
|
||||||
GREEN,
|
GREEN,
|
||||||
INK,
|
INK,
|
||||||
@@ -600,6 +601,9 @@ export default function ContractsList() {
|
|||||||
).toLocaleDateString()
|
).toLocaleDateString()
|
||||||
: "—"}
|
: "—"}
|
||||||
</Text>
|
</Text>
|
||||||
|
{/* Countdown once the contract is inside the notice
|
||||||
|
window — renders nothing before that. */}
|
||||||
|
<ContractExpiryBadge contract={c} />
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ContractStatusBadge status={c.status} />
|
<ContractStatusBadge status={c.status} />
|
||||||
|
|||||||
@@ -623,6 +623,11 @@ export default function NewContractPage({
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
isHazardous: data.isHazardous,
|
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.
|
// Reefer is a contract-level flag for both container and bulk.
|
||||||
isReefer: data.isRefrigerated,
|
isReefer: data.isRefrigerated,
|
||||||
...(data.previousContractRef
|
...(data.previousContractRef
|
||||||
|
|||||||
@@ -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 { 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 { LucideIcon } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import type { Freight } from "@edr/types";
|
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. */
|
/** A labelled value used across the contract detail summary cards. */
|
||||||
export function MetaItem({
|
export function MetaItem({
|
||||||
label,
|
label,
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export function contractToFormValues(
|
|||||||
bulkQuantityCap:
|
bulkQuantityCap:
|
||||||
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
|
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
|
||||||
isHazardous: contract.isHazardous,
|
isHazardous: contract.isHazardous,
|
||||||
|
hazardClass: contract.hazardClass ?? "",
|
||||||
|
unNumber: contract.unNumber ?? "",
|
||||||
isRefrigerated: contract.isReefer,
|
isRefrigerated: contract.isReefer,
|
||||||
|
|
||||||
originYard: primaryRoute?.originYardId ?? "",
|
originYard: primaryRoute?.originYardId ?? "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { DeepPartial, Path } from "react-hook-form";
|
import { DeepPartial, Path } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
|
import { HAZARD_CLASS_VALUES } from "@edr/types";
|
||||||
|
|
||||||
// Wizard steps for the contract creation flow. Condensed to four steps: the
|
// Wizard steps for the contract creation flow. Condensed to four steps: the
|
||||||
// pickers are dropdown selects so each step fits one screen without scrolling.
|
// pickers are dropdown selects so each step fits one screen without scrolling.
|
||||||
@@ -177,6 +178,10 @@ export const contractFormSchema = z
|
|||||||
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
||||||
// Contract-level billing flags.
|
// Contract-level billing flags.
|
||||||
isHazardous: z.boolean().default(false),
|
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),
|
isRefrigerated: z.boolean().default(false),
|
||||||
|
|
||||||
// ── Route ── (one route per contract — general contracts included)
|
// ── 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
|
// GENERAL contracts are uncapped: no quantity cap is collected, so the
|
||||||
// customer can book repeatedly until the contract's validity expires. 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
|
// cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at
|
||||||
@@ -271,6 +294,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
cargoFreeText: "",
|
cargoFreeText: "",
|
||||||
bulkQuantityCap: 0,
|
bulkQuantityCap: 0,
|
||||||
isHazardous: false,
|
isHazardous: false,
|
||||||
|
hazardClass: "",
|
||||||
|
unNumber: "",
|
||||||
isRefrigerated: false,
|
isRefrigerated: false,
|
||||||
|
|
||||||
originYard: "",
|
originYard: "",
|
||||||
@@ -307,6 +332,8 @@ export const contractStepFields: Record<
|
|||||||
"cargoFreeText",
|
"cargoFreeText",
|
||||||
"bulkQuantityCap",
|
"bulkQuantityCap",
|
||||||
"isHazardous",
|
"isHazardous",
|
||||||
|
"hazardClass",
|
||||||
|
"unNumber",
|
||||||
"isRefrigerated",
|
"isRefrigerated",
|
||||||
"originYard",
|
"originYard",
|
||||||
"destinationYard",
|
"destinationYard",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Controller, type UseFormReturn } from "react-hook-form";
|
|||||||
// Snowflake — restore with the Refrigerated Cargo switch below.
|
// Snowflake — restore with the Refrigerated Cargo switch below.
|
||||||
import { Container, Flame, RotateCcw } from "lucide-react";
|
import { Container, Flame, RotateCcw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -13,10 +14,11 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
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 { api } from "@/services/api";
|
||||||
import {
|
import {
|
||||||
CONTAINER_SIZES,
|
CONTAINER_SIZES,
|
||||||
@@ -95,6 +97,18 @@ export function Step3CargoScope({
|
|||||||
const [hazardModalOpen, setHazardModalOpen] = useState(false);
|
const [hazardModalOpen, setHazardModalOpen] = useState(false);
|
||||||
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
|
const [hazardDraft, setHazardDraft] = useState<ContractDocuments>({});
|
||||||
const [hazardErrors, setHazardErrors] = useState<Record<string, string>>({});
|
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. */
|
/** Drop every hazardous document from the contract's document map. */
|
||||||
const clearHazardDocs = () => {
|
const clearHazardDocs = () => {
|
||||||
@@ -117,6 +131,9 @@ export function Step3CargoScope({
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
setHazardErrors({});
|
setHazardErrors({});
|
||||||
|
setClassDraft(form.getValues("hazardClass") || null);
|
||||||
|
setUnDraft(form.getValues("unNumber") ?? "");
|
||||||
|
setDeclErrors({});
|
||||||
setHazardModalOpen(true);
|
setHazardModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,14 +141,20 @@ export function Step3CargoScope({
|
|||||||
const missing = hazardFields.filter(
|
const missing = hazardFields.filter(
|
||||||
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
|
(f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]),
|
||||||
);
|
);
|
||||||
if (missing.length > 0) {
|
const nextDeclErrors: typeof declErrors = {};
|
||||||
setHazardErrors(
|
if (!classDraft) {
|
||||||
Object.fromEntries(
|
nextDeclErrors.hazardClass = "Select the dangerous-goods class.";
|
||||||
missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
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(
|
form.setValue(
|
||||||
"documents",
|
"documents",
|
||||||
{
|
{
|
||||||
@@ -140,16 +163,29 @@ export function Step3CargoScope({
|
|||||||
},
|
},
|
||||||
{ shouldDirty: true },
|
{ 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 });
|
form.setValue("isHazardous", true, { shouldDirty: true });
|
||||||
setHazardModalOpen(false);
|
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
|
// 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.
|
// hazardous, and a non-import contract carries neither reefer nor empty return.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOneTime) return;
|
if (isOneTime) return;
|
||||||
if (form.getValues("isHazardous")) {
|
if (form.getValues("isHazardous")) {
|
||||||
form.setValue("isHazardous", false, { shouldDirty: true });
|
form.setValue("isHazardous", false, { shouldDirty: true });
|
||||||
|
clearHazardDeclaration();
|
||||||
}
|
}
|
||||||
// Runs again once the hazard field list loads — a no-op when nothing matches.
|
// Runs again once the hazard field list loads — a no-op when nothing matches.
|
||||||
clearHazardDocs();
|
clearHazardDocs();
|
||||||
@@ -356,22 +392,32 @@ export function Step3CargoScope({
|
|||||||
name="isHazardous"
|
name="isHazardous"
|
||||||
control={form.control}
|
control={form.control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<ToggleRow
|
<Stack gap={0}>
|
||||||
icon={<Flame size={18} />}
|
<ToggleRow
|
||||||
iconBg="#FBEAE7"
|
icon={<Flame size={18} />}
|
||||||
iconColor="#C0392B"
|
iconBg="#FBEAE7"
|
||||||
title="Hazardous Material"
|
iconColor="#C0392B"
|
||||||
description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents."
|
title="Hazardous Material"
|
||||||
checked={field.value ?? false}
|
description="Applies a hazard surcharge as a per-container unit rate. Requires a UN class, UN number and hazard documents."
|
||||||
onChange={(v) => {
|
checked={field.value ?? false}
|
||||||
if (v) {
|
onChange={(v) => {
|
||||||
openHazardModal();
|
if (v) {
|
||||||
return;
|
openHazardModal();
|
||||||
}
|
return;
|
||||||
field.onChange(false);
|
}
|
||||||
clearHazardDocs();
|
field.onChange(false);
|
||||||
}}
|
clearHazardDocs();
|
||||||
/>
|
clearHazardDeclaration();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{field.value && (
|
||||||
|
<HazardDeclarationSummary
|
||||||
|
hazardClass={hazardClass}
|
||||||
|
unNumber={unNumber}
|
||||||
|
onEdit={openHazardModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -429,17 +475,83 @@ export function Step3CargoScope({
|
|||||||
<Modal
|
<Modal
|
||||||
opened={hazardModalOpen}
|
opened={hazardModalOpen}
|
||||||
onClose={() => setHazardModalOpen(false)}
|
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"
|
size="lg"
|
||||||
centered
|
centered
|
||||||
radius={14}
|
radius={14}
|
||||||
>
|
>
|
||||||
<Stack gap={16}>
|
<Stack gap={18}>
|
||||||
<Text fz={13} c="#6B7C8E">
|
<Text fz={13} c="#6B7C8E">
|
||||||
Hazardous cargo can only move once the documents below are attached
|
Dangerous goods move only once the class and UN number are declared
|
||||||
to the contract.
|
and the documents below are attached to the contract. EDR reviews
|
||||||
|
this declaration in two dedicated hazardous approval steps.
|
||||||
</Text>
|
</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 ? (
|
{hazardSettingQuery.isLoading ? (
|
||||||
<Group justify="center" py="lg">
|
<Group justify="center" py="lg">
|
||||||
<Loader size="sm" color="edr-green" />
|
<Loader size="sm" color="edr-green" />
|
||||||
@@ -468,7 +580,7 @@ export function Step3CargoScope({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
|
<Button color="edr-green" radius={10} onClick={confirmHazardDocs}>
|
||||||
Save & mark hazardous
|
Save declaration
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -477,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({
|
function ToggleRow({
|
||||||
icon,
|
icon,
|
||||||
iconBg,
|
iconBg,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
Truck,
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Freight } from "@edr/types";
|
import { hazardClassLabel, type Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
type ContractFormInputValues,
|
type ContractFormInputValues,
|
||||||
type ContractFormValues,
|
type ContractFormValues,
|
||||||
@@ -418,7 +418,23 @@ export function Step8Review({
|
|||||||
<SummaryItem
|
<SummaryItem
|
||||||
icon={<Package size={18} />}
|
icon={<Package size={18} />}
|
||||||
label="Hazardous cargo"
|
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
|
<SummaryItem
|
||||||
icon={<Package size={18} />}
|
icon={<Package size={18} />}
|
||||||
|
|||||||
@@ -20,6 +20,38 @@ export enum ContractFreightType {
|
|||||||
Bulk = "BULK",
|
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. */
|
/** Who created a shipment booking under a contract. */
|
||||||
export type BookingCreatedByRole = "CUSTOMER" | "GL_ET" | "STAFF";
|
export type BookingCreatedByRole = "CUSTOMER" | "GL_ET" | "STAFF";
|
||||||
|
|
||||||
@@ -428,6 +460,10 @@ export interface ContractClearanceView {
|
|||||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||||
linkedBookingReference?: string | null;
|
linkedBookingReference?: string | null;
|
||||||
linkedBookingStatus?: 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?: {
|
dutyAdvice?: {
|
||||||
amount: number;
|
amount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
@@ -661,6 +697,10 @@ export interface IContract extends BaseEntity {
|
|||||||
lastMileDeliveryLng?: number | null;
|
lastMileDeliveryLng?: number | null;
|
||||||
|
|
||||||
isHazardous: boolean;
|
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;
|
isReefer: boolean;
|
||||||
|
|
||||||
estimatedShipmentDate?: string | null;
|
estimatedShipmentDate?: string | null;
|
||||||
@@ -788,6 +828,10 @@ export interface CreateContractDto {
|
|||||||
lastMileDeliveryLng?: number;
|
lastMileDeliveryLng?: number;
|
||||||
|
|
||||||
isHazardous?: boolean;
|
isHazardous?: boolean;
|
||||||
|
/** Required when `isHazardous` — one of HAZARD_CLASSES. */
|
||||||
|
hazardClass?: string;
|
||||||
|
/** Required when `isHazardous` — the shipment's UN number. */
|
||||||
|
unNumber?: string;
|
||||||
isReefer?: boolean;
|
isReefer?: boolean;
|
||||||
|
|
||||||
estimatedShipmentDate?: string;
|
estimatedShipmentDate?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user