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:
Marshal
2026-07-25 21:10:09 +00:00
parent fde5e6de4b
commit 9a1c8e5603
41 changed files with 1663 additions and 99 deletions

View File

@@ -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,
);
});
});

View File

@@ -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;

View File

@@ -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),
}; };

View File

@@ -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;`,
);
}
}

View File

@@ -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' });
});
});

View File

@@ -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.`;

View File

@@ -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;
}); });
} }

View File

@@ -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');
});
});

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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()

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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');

View File

@@ -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',

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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",
}, },

View File

@@ -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;

View File

@@ -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"

View File

@@ -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),

View File

@@ -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.

View File

@@ -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",

View File

@@ -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)),

View File

@@ -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

View File

@@ -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.

View File

@@ -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} />

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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,

View File

@@ -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 ?? "",

View File

@@ -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",

View File

@@ -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 &amp; 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,

View File

@@ -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} />}

View File

@@ -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;