diff --git a/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts new file mode 100644 index 000000000..4658a5434 --- /dev/null +++ b/apps/edr-freight-api/src/common/freight-permission.hazardous.spec.ts @@ -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, + ); + }); +}); diff --git a/apps/edr-freight-api/src/common/freight-permission.util.ts b/apps/edr-freight-api/src/common/freight-permission.util.ts index 429c910d3..56c0e77c2 100644 --- a/apps/edr-freight-api/src/common/freight-permission.util.ts +++ b/apps/edr-freight-api/src/common/freight-permission.util.ts @@ -151,6 +151,24 @@ const APPROVE_ROLE_PERMISSION: Record = { 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 = { + 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 = { LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff, DIRECTOR: FREIGHT_PERMS.contracts.approveDirector, @@ -183,6 +201,16 @@ export function assertCanApproveContractStep( ): void { if (isFreightApprovalAdmin(user)) return; + // Hazardous steps are permission-only and strict — no legacy alias, no + // blanket approve fallback. + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) { + if (hasFreightPermission(user, hazardousPermission)) return; + throw new ForbiddenException( + `Missing permission: ${hazardousPermission}`, + ); + } + const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return; @@ -219,6 +247,11 @@ export function canEditContractStep( ): boolean { if (isFreightApprovalAdmin(user)) return true; + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) { + return hasFreightPermission(user, hazardousPermission); + } + const positionTypes = collectPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index 5ceb73978..eb9541d8e 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -1,4 +1,5 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { hazardClassLabel } from '@edr/types'; import { ContractsRepository } from '../modules/contracts/contracts.repository'; import { @@ -143,6 +144,11 @@ export class ContractDocumentViewModelBuilder { const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); + // Signed before company stamps were required — the customer has to sign + // again to attach one, otherwise EDR can never counter-sign the contract. + const customerStampMissing = signatures.some( + (s) => s.role === 'CUSTOMER' && !s.stampImageUrl, + ); const hasContractFile = Boolean( contract.files?.some((f) => f.code === 'contract'), ); @@ -185,7 +191,9 @@ export class ContractDocumentViewModelBuilder { // Cast: contract signers (CUSTOMER|STAFF|DIRECTOR|CEO) widen the booking // view-model's narrower CUSTOMER|STAFF role union. signatures: signatures as unknown as ContractViewModel['signatures'], - canSignCustomer: contract.status === 'CONTRACT_READY' && !hasCustomer, + canSignCustomer: + (contract.status === 'CONTRACT_READY' && !hasCustomer) || + (contract.status === 'SIGNED_CUSTOMER' && customerStampMissing), canSignStaff: contract.status === 'SIGNED_CUSTOMER' && hasCustomer && !hasStaff, hasContractDocument: hasContractFile, @@ -295,7 +303,16 @@ export class ContractDocumentViewModelBuilder { cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', equipmentReturn: this.valueOrDash(contract.equipmentReturn), - hazardousLabel: contract.isHazardous ? 'Yes' : 'No', + // A hazardous contract names the declared class + UN number on the + // schedule — the flag alone is not a dangerous-goods declaration. + hazardousLabel: contract.isHazardous + ? [ + hazardClassLabel(contract.hazardClass) ?? 'Yes', + contract.unNumber ? `UN ${contract.unNumber}` : null, + ] + .filter(Boolean) + .join(' · ') + : 'No', firstMilePickupAddress: this.valueOrDash(contract.firstMilePickupAddress), lastMileDeliveryAddress: this.valueOrDash(contract.lastMileDeliveryAddress), }; diff --git a/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts b/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts new file mode 100644 index 000000000..bef24c3e4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2920000000000-AddContractHazardDeclaration.ts @@ -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 { + 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 { + 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;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts new file mode 100644 index 000000000..2c21d804c --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.spec.ts @@ -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 => + ({ + 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' }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts index caa41f5e9..4072a462e 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-lifecycle-notifier.service.ts @@ -179,8 +179,35 @@ export class BookingLifecycleNotifierService { }); } - /** Operations returned the operation request for changes. */ + /** + * Operations returned the operation request for changes. + * + * A customs (Path B) booking was created BY GL Ethiopia on the customer's + * behalf — the customer cannot edit or resubmit it, so telling them to "update + * from the portal" is a dead end. Those go to the GL who created it, linking + * the contract clearance page they work from. Everything else (customer-made + * bookings) keeps the portal message. + */ operationChangesRequested(b: Booking, note: string): void { + if (b.createdByRole === 'GL_ET' && b.createdByUserId) { + const msg = + `Operations returned booking ${b.reference} for changes: ${note}. ` + + `Address it on the contract clearance page and resubmit to Operations.`; + this.logger.log(`OPERATION CHANGES REQUESTED (to GL) — ${this.ref(b)}`); + void this.inbox.notify({ + recipients: { userIds: [b.createdByUserId] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.BOOKING_STATUS, + title: `Booking ${b.reference} needs changes`, + body: msg, + link: b.contractId + ? `/dashboard/contracts/clearance/${b.contractId}` + : `/dashboard/bookings/${b.id}/clearance`, + data: { bookingId: b.id, reference: b.reference, note }, + }); + return; + } + const msg = `Your operation request for booking ${b.reference} needs changes: ${note}. ` + `Please update and resubmit from the portal.`; diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 507ef43d8..06268342b 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -33,41 +33,86 @@ import { BookingReferenceYardDto, } from "./dto/booking-reference-data.dto"; +/** + * Reference cargo tree: top-level groups, each carrying its selectable + * commodities. + * + * `cargo_types` is an arbitrary-depth tree (Bulk → Steel Billet → S1 → …), but + * only a LEAF is a real commodity — an intermediate node is a container for + * finer types, and booking against it would be ambiguous. So each group's + * `children` are all of its leaf descendants, flattened, whatever the depth. + * Deep leaves carry their path below the group ("Steel Billet → S1") so a + * generically-named leaf still reads unambiguously in a dropdown. + * + * A group with no active descendants is its own leaf and is emitted as its + * single child — otherwise it is selectable as a group but offers no commodity, + * which dead-ends every form that requires one. + */ export function buildCargoTypeTree( rows: CargoType[], ): BookingReferenceCargoTypeGroupDto[] { const active = rows.filter((r) => r.isActive); - const parents = active - .filter((r) => !r.parentGroupId) - .sort( - (a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ); + + const byOrder = (a: CargoType, b: CargoType) => + a.displayOrder - b.displayOrder || a.code.localeCompare(b.code); + + const childrenOf = new Map(); + 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, + ): BookingReferenceCargoTypeChildDto[] => { + // Admin-entered parent pointers could in principle cycle — never loop. + if (seen.has(node.id)) return []; + seen.add(node.id); + + const kids = childrenOf.get(node.id) ?? []; + if (kids.length === 0) { + return [ + { + id: node.id, + name: [...trail, node.cargoTypeName].join(" → "), + code: node.code, + unit_of_measure: node.unitOfMeasure ?? null, + }, + ]; + } + const nextTrail = [...trail, node.cargoTypeName]; + return kids.flatMap((kid) => collectLeaves(kid, nextTrail, seen)); + }; return parents.map((parent) => { - const children = active - .filter((r) => r.parentGroupId === parent.id) - .sort( - (a, b) => - a.displayOrder - b.displayOrder || a.code.localeCompare(b.code), - ) - .map( - (child): BookingReferenceCargoTypeChildDto => ({ - id: child.id, - name: child.cargoTypeName, - code: child.code, - unit_of_measure: child.unitOfMeasure ?? null, - }), - ); + const kids = childrenOf.get(parent.id) ?? []; + const children = + kids.length === 0 + ? // The group itself is the commodity. + [ + { + id: parent.id, + name: parent.cargoTypeName, + code: parent.code, + unit_of_measure: parent.unitOfMeasure ?? null, + }, + ] + : kids.flatMap((kid) => collectLeaves(kid, [], new Set())); - const group: BookingReferenceCargoTypeGroupDto = { + return { id: parent.id, name: parent.cargoTypeName, code: parent.code, + children, }; - if (children.length > 0) { - group.children = children; - } - return group; }); } diff --git a/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts new file mode 100644 index 000000000..aaa819505 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/cargo-type-tree.spec.ts @@ -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'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d76391f42..88cb2149b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -84,6 +84,14 @@ export interface ContractClearanceView { /** Reference + status of the GL-created shipment booking, once it exists. */ linkedBookingReference?: string | null; linkedBookingStatus?: string | null; + /** + * Operations' latest "needs changes" note on that booking. GL created the + * booking, so GL is the one who has to act on it — surfaced here because the + * clearance page is where GL works, not the portal. + */ + linkedBookingReviewNote?: string | null; + /** Shipment day the booking currently holds — the default when GL resubmits. */ + linkedBookingScheduledDate?: string | null; dutyAdvice?: { amount: number; currency: string; @@ -301,11 +309,24 @@ export class ContractClearanceService { // shortly" message. Reuse the export booking load; fetch for import too. let linkedBookingReference: string | null = null; let linkedBookingStatus: string | null = null; + let linkedBookingReviewNote: string | null = null; + let linkedBookingScheduledDate: string | null = null; if (cycle?.bookingId) { const booking = await this.bookingsService.findById(cycle.bookingId); if (booking) { linkedBookingReference = booking.reference ?? null; linkedBookingStatus = booking.status ?? null; + linkedBookingScheduledDate = booking.scheduledDate + ? new Date(booking.scheduledDate).toISOString() + : null; + // Newest changes-requested note (reviewNotes ride along on findById). + linkedBookingReviewNote = + [...(booking.reviewNotes ?? [])] + .filter((n) => n.type === 'CHANGES_REQUESTED') + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + )[0]?.note ?? null; if (contract.tradeDirection === 'EXPORT') { nextAction = this.workflowService.computeNextActionForBooking( booking, @@ -349,6 +370,8 @@ export class ContractClearanceService { linkedBookingId: cycle?.bookingId ?? null, linkedBookingReference, linkedBookingStatus, + linkedBookingReviewNote, + linkedBookingScheduledDate, dutyAdvice, workflowFiles, t1, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts new file mode 100644 index 000000000..0551cfc7a --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.spec.ts @@ -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 => + ({ + 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(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts index 439aff804..1ef84c141 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-expiry.service.ts @@ -5,6 +5,13 @@ import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { ContractsRepository } from './contracts.repository'; +/** + * How many days before a contract lapses the customer is reminded. Mirrored by + * the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows + * the same countdown on the row. + */ +const EXPIRY_NOTICE_DAYS = 10; + /** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */ @Injectable() export class ContractExpiryService { @@ -15,6 +22,51 @@ export class ContractExpiryService { 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 { + try { + const expiring = + await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS); + let notified = 0; + for (const contract of expiring) { + if (!contract.companyId || !contract.contractValidUntil) continue; + const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB'); + await this.inbox.notify({ + recipients: { companyId: contract.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.CONTRACT_STATUS, + title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`, + body: + `Your contract ${contract.reference} is valid until ${endsOn}. ` + + 'After that date it stops accepting new bookings — contact EDR if ' + + 'you need it renewed.', + link: '/contracts', + data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' }, + }); + notified += 1; + } + this.logger.log( + `Contract expiry reminder: ${notified} customer(s) warned of a contract ` + + `lapsing in ${EXPIRY_NOTICE_DAYS} days`, + ); + } catch (err) { + // Never throws into the scheduler — a failed reminder must not stop the + // expiry sweep from running. + this.logger.error( + `Contract expiry reminder failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + @Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' }) async expireLapsedContracts(): Promise { try { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts new file mode 100644 index 000000000..2c8bc8fa7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-stamp-resign.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index ea17b26a5..5a22d2fb9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -22,6 +22,7 @@ import { assertCanApproveContractStep, assertFreightPermission, canEditContractStep, + HAZARDOUS_APPROVAL_ROLES, } from '../../common/freight-permission.util'; import { FREIGHT_PERMS, @@ -530,12 +531,26 @@ export class ContractTransitionService { ); } - for (const rule of chain) { - await this.contractsRepository.createApprovalStep({ - contractId: contract.id, - stepOrder: rule.stepOrder, + // Dangerous goods clear two dedicated hazardous desks BEFORE the commercial + // chain — if either refuses, the contract never reaches the approvers who + // would price and sign it. Steps are renumbered sequentially so the prefix + // and the configured chain form one ordered list. + const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [ + ...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map( + (requiredRole) => ({ requiredRole, blocksRole: null }), + ), + ...chain.map((rule) => ({ requiredRole: rule.requiredRole, blocksRole: rule.blocksRole ?? null, + })), + ]; + + for (const [index, role] of roles.entries()) { + await this.contractsRepository.createApprovalStep({ + contractId: contract.id, + stepOrder: index + 1, + requiredRole: role.requiredRole, + blocksRole: role.blocksRole, status: 'PENDING', }); } @@ -1111,7 +1126,17 @@ export class ContractTransitionService { options.signerUserId, contract, ); - assertContractStatus(contract, ['CONTRACT_READY']); + // SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that + // {@link sign} permits — otherwise the code would be useless on arrival. + const existing = await this.contractsRepository.findSignature( + contractId, + 'CUSTOMER', + ); + const addingMissingStamp = Boolean(existing) && !existing?.stampFileId; + assertContractStatus( + contract, + addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'], + ); const signerContacts = await this.resolveSignerContacts(options.signerUserId); await this.otpService.sendOtp(signerContacts); @@ -1135,9 +1160,16 @@ export class ContractTransitionService { options.signerUserId, contract, ); - assertContractStatus(contract, ['CONTRACT_READY']); const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER'); - if (existing) { + // Signing is one-shot, with one exception: a contract signed before the + // company stamp was required has to be sealed before EDR can counter-sign + // it, so the customer may sign again purely to attach the missing stamp. + const addingMissingStamp = Boolean(existing) && !existing?.stampFileId; + assertContractStatus( + contract, + addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'], + ); + if (existing && !addingMissingStamp) { throw new BadRequestException('Customer has already signed this contract'); } // Sudo-mode gate: a fresh, single-use OTP must be verified before the diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index d71a6bd78..6b16d5647 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -107,6 +107,30 @@ export class ContractsRepository extends BaseRepository { 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 { + const now = Date.now(); + return this.repository + .createQueryBuilder('contract') + .where('contract.deleted_at IS NULL') + .andWhere('contract.status NOT IN (:...terminal)', { + terminal: TERMINAL_CONTRACT_STATUSES, + }) + .andWhere('contract.contract_valid_until >= :from', { + from: new Date(now + days * 86_400_000), + }) + .andWhere('contract.contract_valid_until < :to', { + to: new Date(now + (days + 1) * 86_400_000), + }) + .getMany(); + } + /** Find a contract by ID with all child collections, service type, company and files. */ async findByIdWithRelations(id: string): Promise { if (!id) return null; diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 0247ac917..02459f2a5 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -343,6 +343,10 @@ export class ContractsService { lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null, lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, + // Hazard class / UN number only exist on a hazardous contract — a stale + // pair from an earlier draft must never survive the flag being turned off. + hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null, + unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null, isReefer: dto.isReefer ?? false, contractType: dto.contractType ?? null, status: 'DRAFT', @@ -515,6 +519,13 @@ export class ContractsService { paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency, isHazardous: dto.isHazardous ?? existing.isHazardous, isReefer: dto.isReefer ?? existing.isReefer, + // Same rule as create: clearing the flag clears the declaration with it. + hazardClass: (dto.isHazardous ?? existing.isHazardous) + ? (dto.hazardClass ?? existing.hazardClass ?? null) + : null, + unNumber: (dto.isHazardous ?? existing.isHazardous) + ? (dto.unNumber ?? existing.unNumber ?? null) + : null, equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn, firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress, firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat, diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index fb4f40654..6e68765f1 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -17,6 +17,8 @@ import { ValidateNested, } from 'class-validator'; +import { HAZARD_CLASS_VALUES } from '@edr/types'; + import { CONTRACT_KINDS } from '../entities/contract.entity'; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const; @@ -228,6 +230,28 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isHazardous?: boolean; + @ApiPropertyOptional({ + enum: HAZARD_CLASS_VALUES, + description: 'UN/ADR dangerous-goods class. Required when isHazardous.', + }) + @ValidateIf((o: CreateContractDto) => o.isHazardous === true) + @IsIn(HAZARD_CLASS_VALUES, { + message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`, + }) + hazardClass?: string; + + @ApiPropertyOptional({ + description: 'UN number of the dangerous good. Required when isHazardous.', + }) + @ValidateIf((o: CreateContractDto) => o.isHazardous === true) + @IsString() + @MinLength(1) + @MaxLength(16) + @Transform(({ value }) => + typeof value === 'string' ? value.trim().toUpperCase() : value, + ) + unNumber?: string; + @ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' }) @IsOptional() @IsBoolean() diff --git a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts index b8635199d..3fe48ea27 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/contract.entity.ts @@ -188,6 +188,14 @@ export class Contract extends BaseEntity { @Column({ name: 'is_hazardous', type: 'boolean', default: false }) isHazardous!: boolean; + /** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */ + @Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true }) + hazardClass?: string | null; + + /** UN number of the dangerous good; null unless hazardous. */ + @Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true }) + unNumber?: string | null; + @Column({ name: 'is_reefer', type: 'boolean', default: false }) isReefer!: boolean; diff --git a/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts new file mode 100644 index 000000000..43194ce08 --- /dev/null +++ b/apps/edr-freight-api/src/modules/routes/routes.duplicate.spec.ts @@ -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 => + ( + service as unknown as { + assertNotDuplicate: ( + m: Array<{ yardId: string }>, + id?: string, + ) => Promise; + } + ).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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/routes/routes.service.ts b/apps/edr-freight-api/src/modules/routes/routes.service.ts index 96e6c7fd1..855f8ec02 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.service.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { TrainScheduleStatus } from '@edr/types'; -import { DataSource, In } from 'typeorm'; +import { DataSource, In, Not } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; import { Yard } from '../rule-engine/entities/yard.entity'; @@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; import { RouteMilestone } from './entities/route-milestone.entity'; -import { formatRouteLabel, Route } from './entities/route.entity'; +import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity'; import { RoutesRepository } from './routes.repository'; /** Order-insensitive key: distances are symmetric. */ @@ -88,6 +88,7 @@ export class RoutesService { async create(dto: CreateRouteDto): Promise { const validated = await this.validateMilestones(dto.milestones); + await this.assertNotDuplicate(validated.milestones); const route = await this.dataSource.transaction(async (manager) => { const savedRoute = await manager.getRepository(Route).save( @@ -123,6 +124,11 @@ export class RoutesService { ? await this.validateMilestones(dto.milestones) : null; + // An edit can collide with another route just as easily as a create can. + if (milestoneInput) { + await this.assertNotDuplicate(milestoneInput.milestones, id); + } + // Milestones or endpoints are about to be rewritten — reject if any // non-terminal schedule still references this route, otherwise its stop list // and distances would silently shift under a live plan. Status-only / @@ -187,6 +193,51 @@ export class RoutesService { return this.findById(id); } + /** + * A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and + * "Addis → Dire Dawa" share endpoints but are different corridors. So the + * duplicate test compares the full yard sequence, not just origin/destination. + * + * Decommissioned routes (STOP_WORKING) are ignored: replacing a retired + * corridor with a fresh one is exactly what an admin does after deactivating, + * and there is no reactivate action to fall back on. + */ + private async assertNotDuplicate( + milestones: Array<{ yardId: string }>, + excludeRouteId?: string, + ): Promise { + 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('STOP_WORKING'), + }, + relations: { + originYard: true, + destinationYard: true, + milestones: { yard: true }, + }, + }); + + const duplicate = candidates.find((route) => { + if (route.id === excludeRouteId) return false; + const stops = [...(route.milestones ?? [])] + .sort((a, b) => a.sequenceNo - b.sequenceNo) + .map((m) => m.yardId) + .join('>'); + return stops === signature; + }); + + if (duplicate) { + throw new ConflictException( + `This route already exists: ${formatRouteLabel(duplicate)}. ` + + 'Edit the existing route instead of creating a duplicate.', + ); + } + } + private async validateMilestones(milestones: Array<{ yardId: string }>) { if (milestones.length < 2) { throw new BadRequestException('A route requires at least two yards'); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index cf57a9a81..65ca3bcde 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -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-00000000000f', 'edr_freight_app:contracts:clearance_dj_actions', 'GL Djibouti phased clearance actions'), perm('a3000001-0001-4000-8000-000000000010', 'edr_freight_app:contracts:clearance_duty_advise', 'Advise contract duty/tax'), + // Hazardous contracts get two extra approval steps ahead of the normal chain. + // Each has its own permission so the two desks are genuinely separate people. + perm('a3000001-0001-4000-8000-000000000019', 'edr_freight_app:contracts:hazardous_approval_one', 'Hazardous approval — first review'), + perm('a3000001-0001-4000-8000-00000000001a', 'edr_freight_app:contracts:hazardous_approval_two', 'Hazardous approval — second review'), ]; // Existing per-slug view ids are kept as-is: position-type grants reference @@ -423,6 +427,8 @@ export const FREIGHT_PERMS = { approveLineStaff: 'edr_freight_app:contracts:approve_line_staff', approveDirector: 'edr_freight_app:contracts:approve_director', approveCeo: 'edr_freight_app:contracts:approve_ceo', + hazardousApprovalOne: 'edr_freight_app:contracts:hazardous_approval_one', + hazardousApprovalTwo: 'edr_freight_app:contracts:hazardous_approval_two', generateContract: 'edr_freight_app:contracts:generate_contract', signStaff: { bulk: 'edr_freight_app:contracts:sign_staff:bulk', diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx new file mode 100644 index 000000000..16b1ab602 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/BookingChangesRequestedAlert.tsx @@ -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( + 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 ( + } + title={`Operations returned booking ${reference ?? ""} for changes`.trim()} + > + + {note ? ( + + + What Operations asked for + + + {note} + + + ) : ( + + Operations returned this booking without a note — contact them for + the detail before resubmitting. + + )} + + + 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.{" "} + + Open the booking → + + + + {canResubmit ? ( + + setDay(v ? new Date(v) : null)} + minDate={new Date()} + size="sm" + w={230} + /> + + + ) : null} + + + ); +} + +export default BookingChangesRequestedAlert; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx index 25f877de3..3747c2d2d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractApprovalStepsCard.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { Check, ShieldCheck, X } from "lucide-react"; +import { Check, Flame, ShieldCheck, X } from "lucide-react"; import { Stack, Group, @@ -14,10 +14,22 @@ import { import type { Freight } from "@edr/types"; import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; +import { HazardDeclarationPanel } from "./HazardDeclarationPanel"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import type { useContractMutations } from "@/hooks/contracts/useContracts"; import { useAuth } from "@/auth/useAuth"; -import { canApproveContractStep } from "@/lib/permissions"; +import { + canApproveContractStep, + CONTRACT_APPROVAL_ROLE_LABELS, + HAZARDOUS_APPROVAL_ROLE_PERMISSION, +} from "@/lib/permissions"; + +/** Chain roles that exist only because the contract carries dangerous goods. */ +const isHazardStep = (requiredRole: string): boolean => + requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION; + +const roleLabel = (requiredRole: string): string => + CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole; type Mutations = ReturnType; @@ -126,7 +138,7 @@ export function ContractApprovalStepsCard({ const subtitle = summary.detail || (nextPending - ? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}` + ? `Next: ${roleLabel(nextPending.requiredRole)} · step ${nextPending.stepOrder}` : steps.length ? "All steps complete" : "Accept submission to begin"); @@ -196,7 +208,7 @@ export function ContractApprovalStepsCard({ You are about to approve the{" "} - {pendingStep?.requiredRole} + {roleLabel(pendingStep?.requiredRole ?? "")} {" "} step for contract{" "} @@ -204,6 +216,9 @@ export function ContractApprovalStepsCard({ . This action cannot be undone from this screen. + {pendingStep && isHazardStep(pendingStep.requiredRole) && ( + + )} @@ -333,6 +348,7 @@ function StepRow({ : isNext ? "edr-green" : "gray"; + const hazard = isHazardStep(step.requiredRole); return ( @@ -371,9 +395,23 @@ function StepRow({ {step.stepOrder} - - {step.requiredRole} - + + + {roleLabel(step.requiredRole)} + + {hazard && ( + } + style={{ flexShrink: 0 }} + > + Hazmat + + )} + {step.note && ( {step.note} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx new file mode 100644 index 000000000..56eb794c7 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/HazardDeclarationPanel.tsx @@ -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; +}) { + const classLabel = hazardClassLabel(contract.hazardClass); + + return ( + + + + + + + Dangerous goods declaration + + + + {classLabel ?? "Class not declared"} + + + {contract.unNumber ? `UN ${contract.unNumber}` : "UN number not declared"} + + + + Check the declaration against the uploaded hazard documents before + approving. + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 032411b26..48c581519 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -156,6 +156,8 @@ export const URL_CONSTANTS = { `/bookings/${id}/clearance/ro-amendment`, CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`, + // Re-request operation after Operations sent the booking back for changes. + CLEARANCE_PROCEED: (id: string) => `/bookings/${id}/clearance/proceed`, CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue", CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue", }, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 01bca1e11..88b5872d3 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -46,6 +46,8 @@ export const FREIGHT_PERMS = { approveLineStaff: "edr_freight_app:contracts:approve_line_staff", approveDirector: "edr_freight_app:contracts:approve_director", approveCeo: "edr_freight_app:contracts:approve_ceo", + hazardousApprovalOne: "edr_freight_app:contracts:hazardous_approval_one", + hazardousApprovalTwo: "edr_freight_app:contracts:hazardous_approval_two", generateContract: "edr_freight_app:contracts:generate_contract", signStaff: { bulk: "edr_freight_app:contracts:sign_staff:bulk", @@ -442,6 +444,22 @@ const CONTRACT_APPROVE_ROLE_PERMISSION: Record = { 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 = { + 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 = { + HAZARDOUS_APPROVAL_ONE: "Hazardous review — first approver", + HAZARDOUS_APPROVAL_TWO: "Hazardous review — second approver", +}; + /** * Can this user action a contract approval step requiring `requiredRole`? * @@ -464,6 +482,10 @@ export function canApproveContractStep( if (!user || !requiredRole) return false; if (isFreightApprovalAdmin(user)) return true; + // Hazardous steps are permission-only — no position type stands in for them. + const hazardousPermission = HAZARDOUS_APPROVAL_ROLE_PERMISSION[requiredRole]; + if (hazardousPermission) return hasPermission(user, hazardousPermission); + const positionTypes = getPositionTypeKeys(user); if (positionTypes.includes(requiredRole)) return true; diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 1ed7d71ec..68b3443c8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -35,6 +35,7 @@ import { isDjiboutiGl, } from "@/lib/permissions"; +import { BookingChangesRequestedAlert } from "@/components/contracts/BookingChangesRequestedAlert"; import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; @@ -137,10 +138,15 @@ export default function ContractClearanceDetailPage() { // The GL-created booking expired unpaid — the slot is free again and GL // rebooks on the customer's behalf (customs bookings are never self-booked). const bookingExpired = clearance?.linkedBookingStatus === "EXPIRED"; - const canRebook = - bookingExpired && + // Operations sent the GL-created booking back. GL owns customs bookings, so + // the note and the resubmit belong here, not in the customer's portal. + const bookingNeedsChanges = + clearance?.linkedBookingStatus === "OPERATION_CHANGES_REQUESTED"; + const isGlBookingOwner = hasPermission(user, FREIGHT_PERMS.contracts.createBooking) && !isDjiboutiGl(user); + const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner; + const canRebook = bookingExpired && isGlBookingOwner; const rebookHref = linkedBookingId ? `${bookingHref}?copyFrom=${linkedBookingId}` : bookingHref; @@ -294,6 +300,19 @@ export default function ContractClearanceDetailPage() { ) : null} + ) : bookingNeedsChanges && linkedBookingId ? ( + { + void refetch(); + void refetchContract(); + refetchBookingMilestonesIfLinked(); + }} + /> ) : bookingAlreadyCreated ? ( ) : null} + {contract.isHazardous ? ( + + + + ) : null} {(contract.cargoScope ?? []).length === 0 ? ( No cargo scope lines. diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 0f2dd1f25..a6e708784 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -2533,6 +2533,13 @@ export const api = { bookingsService.reviewOperation(id, decision, { note }), ), + proceedToOperation: endpoint< + { id: string; scheduledDate: string }, + BookingDetail + >("bookings", "proceedToOperation", ({ id, scheduledDate }) => + bookingsService.proceedToOperation(id, scheduledDate), + ), + generateContract: endpoint<{ id: string }, BookingDetail>( "bookings", "generateContract", diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index d814ceac6..fce85102e 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -243,6 +243,14 @@ export const bookingsService = { ...options, }), + /** + * Re-request operation on a booking Operations sent back for changes. The + * customer path uses the same endpoint from the portal; GL needs it here + * because a customs booking is GL's to fix, not the customer's. + */ + proceedToOperation: (id: string, scheduledDate: string) => + postBooking(B.CLEARANCE_PROCEED(id), { scheduledDate }), + generateContract: (id: string) => postBooking(B.CONTRACT_GENERATE(id)), diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx index 3278e34e1..bc7d55691 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractStepBanner.tsx @@ -15,7 +15,14 @@ import { import type { LucideIcon } from "lucide-react"; import type { Freight } from "@edr/types"; -import { BORDER, GREEN, GREEN_DARK, INK, MUTED } from "./contract-ui"; +import { + BORDER, + GREEN, + GREEN_DARK, + INK, + MUTED, + expiryNoticeDays, +} from "./contract-ui"; /** * The customer-facing contract journey, in order. This is the *contract track* @@ -124,15 +131,6 @@ function resolveStep(status: string): StepState { } } -/** Days until the contract validity lapses, if any (negative = already lapsed). */ -function daysUntil(dateIso?: string | null): number | null { - if (!dateIso) return null; - const end = new Date(dateIso).getTime(); - if (Number.isNaN(end)) return null; - const ms = end - Date.now(); - return Math.ceil(ms / 86_400_000); -} - export interface ContractStepBannerProps { contract: Freight.IContract; } @@ -145,9 +143,9 @@ export function ContractStepBanner({ contract }: ContractStepBannerProps) { const { activeIdx, terminal, next } = resolveStep(contract.status); const isTerminalBad = terminal === "REJECTED" || terminal === "CANCELLED" || terminal === "EXPIRED"; - const expiryDays = daysUntil(contract.contractValidUntil); - const expirySoon = - !terminal && expiryDays !== null && expiryDays >= 0 && expiryDays <= 14; + // Same notice window as the list badge and the API's reminder. + const expiryDays = expiryNoticeDays(contract); + const expirySoon = !terminal && expiryDays !== null; return ( + {/* 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" && ( + + This contract was signed before a company stamp was required. Please + sign again and attach your stamp so EDR can counter-sign it. + + )} + {data.canSignCustomer && !hasScrolledToBottom && ( Please scroll through the entire contract before signing. diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx index bd447db73..f465ca849 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -43,6 +43,7 @@ import { usePagination } from "@edr/ui-common"; import { BORDER, ContractDocButton, + ContractExpiryBadge, ContractStatusBadge, GREEN, INK, @@ -600,6 +601,9 @@ export default function ContractsList() { ).toLocaleDateString() : "—"} + {/* Countdown once the contract is inside the notice + window — renders nothing before that. */} + diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index e0f2d11a8..0acd5d907 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -623,6 +623,11 @@ export default function NewContractPage({ } : {}), isHazardous: data.isHazardous, + // The dangerous-goods declaration only travels with the flag — the API + // rejects a hazardous contract that omits either field. + ...(data.isHazardous + ? { hazardClass: data.hazardClass, unNumber: data.unNumber } + : {}), // Reefer is a contract-level flag for both container and bulk. isReefer: data.isRefrigerated, ...(data.previousContractRef diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts b/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts new file mode 100644 index 000000000..2b2ca987c --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-expiry-notice.test.ts @@ -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 = {}) => + ({ + 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"); + }); +}); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx index 7028943df..4043fc71d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx @@ -1,5 +1,5 @@ import { Box, Group, Paper, Text, Tooltip } from "@mantine/core"; -import { FileText } from "lucide-react"; +import { AlertTriangle, FileText } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import type { ReactNode } from "react"; import type { Freight } from "@edr/types"; @@ -209,6 +209,88 @@ export function ContractStatusBadge({ status }: { status: string }) { ); } +/** + * How close to its validity end a contract has to be before the customer is + * warned. The API notifies at the same distance (contract-expiry.service), so + * the inbox message and the list badge agree. + */ +export const EXPIRY_NOTICE_DAYS = 10; + +/** Whole days until a date (0 = today, negative = already past). Null if unset. */ +export function daysUntil(dateIso?: string | null): number | null { + if (!dateIso) return null; + const end = new Date(dateIso).getTime(); + if (Number.isNaN(end)) return null; + return Math.ceil((end - Date.now()) / 86_400_000); +} + +/** Contracts that are already over — no point warning about their expiry. */ +const CLOSED_CONTRACT_STATUSES = [ + "REJECTED", + "CANCELLED", + "CONTRACT_CLOSED", + "ARCHIVED", + "EXPIRED", +]; + +/** + * Days left on a live contract, but only inside the notice window — null when + * the contract is closed, has no validity date, has already lapsed, or is still + * further out than {@link EXPIRY_NOTICE_DAYS}. + */ +export function expiryNoticeDays( + contract: Pick, +): 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; +}) { + const days = expiryNoticeDays(contract); + if (days == null) return null; + return ( + + + + + {expiryNoticeLabel(days)} + + + + ); +} + /** A labelled value used across the contract detail summary cards. */ export function MetaItem({ label, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index 38a7b3463..21de86b42 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -122,6 +122,8 @@ export function contractToFormValues( bulkQuantityCap: isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0, isHazardous: contract.isHazardous, + hazardClass: contract.hazardClass ?? "", + unNumber: contract.unNumber ?? "", isRefrigerated: contract.isReefer, originYard: primaryRoute?.originYardId ?? "", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index d20719c24..3105ac485 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -1,5 +1,6 @@ import { DeepPartial, Path } from "react-hook-form"; import * as z from "zod"; +import { HAZARD_CLASS_VALUES } from "@edr/types"; // Wizard steps for the contract creation flow. Condensed to four steps: the // pickers are dropdown selects so each step fits one screen without scrolling. @@ -177,6 +178,10 @@ export const contractFormSchema = z bulkQuantityCap: nonNegativeQuantityCap.default(0), // Contract-level billing flags. isHazardous: z.boolean().default(false), + // Dangerous-goods declaration — collected with the hazard documents and + // mandatory whenever isHazardous (enforced in the superRefine below). + hazardClass: z.string().default(""), + unNumber: z.string().default(""), isRefrigerated: z.boolean().default(false), // ── Route ── (one route per contract — general contracts included) @@ -241,6 +246,24 @@ export const contractFormSchema = z }); } } + // Hazardous cargo must name its UN/ADR class and UN number — the API + // rejects the contract otherwise, so catch it before the wizard submits. + if (data.isHazardous) { + if (!HAZARD_CLASS_VALUES.includes(data.hazardClass)) { + ctx.addIssue({ + code: "custom", + path: ["hazardClass"], + message: "Select the dangerous-goods class.", + }); + } + if (!data.unNumber.trim()) { + ctx.addIssue({ + code: "custom", + path: ["unNumber"], + message: "Enter the UN number.", + }); + } + } // GENERAL contracts are uncapped: no quantity cap is collected, so the // customer can book repeatedly until the contract's validity expires. The // cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at @@ -271,6 +294,8 @@ export const initialContractFormValues: DeepPartial = { cargoFreeText: "", bulkQuantityCap: 0, isHazardous: false, + hazardClass: "", + unNumber: "", isRefrigerated: false, originYard: "", @@ -307,6 +332,8 @@ export const contractStepFields: Record< "cargoFreeText", "bulkQuantityCap", "isHazardous", + "hazardClass", + "unNumber", "isRefrigerated", "originYard", "destinationYard", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 1afc08186..84248d3e7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -3,6 +3,7 @@ import { Controller, type UseFormReturn } from "react-hook-form"; // Snowflake — restore with the Refrigerated Cargo switch below. import { Container, Flame, RotateCcw } from "lucide-react"; import { + Badge, Box, Button, Group, @@ -13,10 +14,11 @@ import { Stack, Switch, Text, + TextInput, } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { SmartFileInput } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; +import { HAZARD_CLASSES, hazardClassLabel, type Freight } from "@edr/types"; import { api } from "@/services/api"; import { CONTAINER_SIZES, @@ -95,6 +97,18 @@ export function Step3CargoScope({ const [hazardModalOpen, setHazardModalOpen] = useState(false); const [hazardDraft, setHazardDraft] = useState({}); const [hazardErrors, setHazardErrors] = useState>({}); + // 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(null); + const [unDraft, setUnDraft] = useState(""); + const [declErrors, setDeclErrors] = useState<{ + hazardClass?: string; + unNumber?: string; + }>({}); + + const hazardClass = form.watch("hazardClass"); + const unNumber = form.watch("unNumber"); /** Drop every hazardous document from the contract's document map. */ const clearHazardDocs = () => { @@ -117,6 +131,9 @@ export function Step3CargoScope({ ), ); setHazardErrors({}); + setClassDraft(form.getValues("hazardClass") || null); + setUnDraft(form.getValues("unNumber") ?? ""); + setDeclErrors({}); setHazardModalOpen(true); }; @@ -124,14 +141,20 @@ export function Step3CargoScope({ const missing = hazardFields.filter( (f) => f.isRequired && !hasUploaded(hazardDraft[f.fileKey]), ); - if (missing.length > 0) { - setHazardErrors( - Object.fromEntries( - missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]), - ), - ); - return; + const nextDeclErrors: typeof declErrors = {}; + if (!classDraft) { + nextDeclErrors.hazardClass = "Select the dangerous-goods class."; } + if (!unDraft.trim()) nextDeclErrors.unNumber = "Enter the UN number."; + + setHazardErrors( + Object.fromEntries( + missing.map((f) => [f.fileKey, `${f.fileLabel} is required.`]), + ), + ); + setDeclErrors(nextDeclErrors); + if (missing.length > 0 || Object.keys(nextDeclErrors).length > 0) return; + form.setValue( "documents", { @@ -140,16 +163,29 @@ export function Step3CargoScope({ }, { shouldDirty: true }, ); + form.setValue("hazardClass", classDraft!, { shouldDirty: true }); + form.setValue("unNumber", unDraft.trim().toUpperCase(), { + shouldDirty: true, + }); + form.clearErrors(["hazardClass", "unNumber"]); form.setValue("isHazardous", true, { shouldDirty: true }); setHazardModalOpen(false); }; + /** Turning the switch off drops the declaration with the documents. */ + const clearHazardDeclaration = () => { + form.setValue("hazardClass", "", { shouldDirty: true }); + form.setValue("unNumber", "", { shouldDirty: true }); + form.clearErrors(["hazardClass", "unNumber"]); + }; + // A hidden flag must never leak into the payload: a general contract can't be // hazardous, and a non-import contract carries neither reefer nor empty return. useEffect(() => { if (isOneTime) return; if (form.getValues("isHazardous")) { form.setValue("isHazardous", false, { shouldDirty: true }); + clearHazardDeclaration(); } // Runs again once the hazard field list loads — a no-op when nothing matches. clearHazardDocs(); @@ -356,22 +392,32 @@ export function Step3CargoScope({ name="isHazardous" control={form.control} render={({ field }) => ( - } - iconBg="#FBEAE7" - iconColor="#C0392B" - title="Hazardous Material" - description="Applies a hazard surcharge as a per-container unit rate. Requires hazard documents." - checked={field.value ?? false} - onChange={(v) => { - if (v) { - openHazardModal(); - return; - } - field.onChange(false); - clearHazardDocs(); - }} - /> + + } + iconBg="#FBEAE7" + iconColor="#C0392B" + title="Hazardous Material" + description="Applies a hazard surcharge as a per-container unit rate. Requires a UN class, UN number and hazard documents." + checked={field.value ?? false} + onChange={(v) => { + if (v) { + openHazardModal(); + return; + } + field.onChange(false); + clearHazardDocs(); + clearHazardDeclaration(); + }} + /> + {field.value && ( + + )} + )} /> )} @@ -429,17 +475,83 @@ export function Step3CargoScope({ setHazardModalOpen(false)} - title="Hazardous cargo documents" + title={ + + + + + + Hazardous cargo declaration + + + } size="lg" centered radius={14} > - + - Hazardous cargo can only move once the documents below are attached - to the contract. + Dangerous goods move only once the class and UN number are declared + and the documents below are attached to the contract. EDR reviews + this declaration in two dedicated hazardous approval steps. + + Dangerous-goods declaration +
+