From e1c831211f430e9e0b599debe543e28237ca99c0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 28 Jul 2026 09:01:23 +0000 Subject: [PATCH] Pre-declaration Djibouti GL assignee request flow --- ...3010000000000-AddBookingTransitAssignee.ts | 31 +++ .../3020000000000-AddContractSubmittedAt.ts | 23 ++ .../booking-lifecycle-notifier.service.ts | 30 +++ .../modules/bookings/bookings.controller.ts | 28 +++ .../bookings/entities/booking.entity.ts | 17 ++ .../booking-clearance.service.spec.ts | 90 ++++++- .../contracts/booking-clearance.service.ts | 78 +++++++ .../contracts/contract-transition.service.ts | 2 + .../contracts/entities/contract.entity.ts | 4 + .../contracts/ContractMilestonesTimeline.tsx | 220 ++++++++++++++++++ .../contracts/PhasedClearanceActionPanel.tsx | 4 +- .../contracts/TransitAssigneePanel.tsx | 19 +- .../backoffice/src/constants/URLS.ts | 4 + .../contracts/ContractRequestDetailPage.tsx | 25 +- .../pages/contracts/GlClearanceDetailPage.tsx | 12 +- .../src/services/bookings.service.ts | 12 + packages/types/src/freight/contracts.ts | 3 + packages/types/src/freight/index.ts | 11 + 18 files changed, 593 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts create mode 100644 apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx diff --git a/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts b/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts new file mode 100644 index 000000000..50fb9db2b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3010000000000-AddBookingTransitAssignee.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Transit-assignee handshake on the SHIPMENT, not the contract. + * + * Clearance runs per booking now, so the ask GL Ethiopia raises before filing a + * customs declaration ("who handles this shipment in Djibouti?") and Djibouti's + * answer belong on the booking. The contract-cycle columns added by + * 2950000000000 stay for the legacy contract-level cycles. + */ +export class AddBookingTransitAssignee3010000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS transit_assignee_requested_at timestamptz NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_request_note text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_name text NULL, + ADD COLUMN IF NOT EXISTS transit_assignee_assigned_at timestamptz NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS transit_assignee_requested_at, + DROP COLUMN IF EXISTS transit_assignee_request_note, + DROP COLUMN IF EXISTS transit_assignee_name, + DROP COLUMN IF EXISTS transit_assignee_assigned_at + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts b/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts new file mode 100644 index 000000000..f2c579f6b --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3020000000000-AddContractSubmittedAt.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A contract's `created_at` is the DRAFT row's insert time, not when the + * customer actually submitted it for review — a DRAFT can sit edited for days + * first. `submitted_at` is stamped by ContractTransitionService.submit / + * confirmSubmit so the history UI can show a real submission time. + */ +export class AddContractSubmittedAt3020000000000 implements MigrationInterface { + name = 'AddContractSubmittedAt3020000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts ADD COLUMN IF NOT EXISTS submitted_at timestamptz;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.contracts DROP COLUMN IF EXISTS submitted_at;`, + ); + } +} 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 6bef20630..132652909 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 @@ -263,6 +263,36 @@ export class BookingLifecycleNotifierService { this.inApp(b, 'Booking cancelled', msg); } + /** + * GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and + * deep-linked to the Djibouti clearance page where the name is entered — the + * customs declaration is blocked until they answer. + */ + transitAssigneeRequested(b: Booking, note: string | null): void { + const msg = + `GL Ethiopia needs a transit assignee for shipment ${b.reference} before ` + + `the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`; + this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${this.ref(b)}`); + this.inAppStaff(b, `Transit assignee needed — ${b.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/gl-djibouti/clearance/${b.id}`, + }); + } + + /** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */ + transitAssigneeAssigned(b: Booking, assignee: string, previous: string | null): void { + const msg = previous + ? `GL Djibouti changed the transit assignee for shipment ${b.reference} from ` + + `"${previous}" to "${assignee}".` + : `GL Djibouti assigned ${assignee} to handle shipment ${b.reference} in transit. ` + + `The customs declaration can now be filed.`; + this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${this.ref(b)}`); + this.inAppStaff(b, `Transit assignee set — ${b.reference}`, msg, { + type: NotificationType.CLEARANCE_REVIEW, + link: `/dashboard/bookings/${b.id}/clearance`, + }); + } + // ── Clearance milestones needing customer action ────────────────────────── /** GL advised duty & tax — the customer must pay and upload the slip. */ diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index 4ebfc9fab..8fb15c74c 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -823,6 +823,34 @@ export class BookingsController { return this.transitionService.enrichBookingResponse(booking); } + @Post(':id/clearance/transit-assignee/request') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) + @ApiOperation({ + summary: + 'GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration', + }) + async requestBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('note') note: string | undefined, + ) { + const booking = await this.bookingClearanceService.requestTransitAssignee(id, note); + return this.transitionService.enrichBookingResponse(booking); + } + + @Post(':id/clearance/transit-assignee/assign') + @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) + @ApiOperation({ + summary: + 'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns', + }) + async assignBookingTransitAssignee( + @Param('id', ParseUUIDPipe) id: string, + @Body('assignee') assignee: string, + ) { + const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee); + return this.transitionService.enrichBookingResponse(booking); + } + @Post(':id/clearance/declaration') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @UseInterceptors(AnyFilesInterceptor()) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 404cf0970..dbe837516 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -543,6 +543,23 @@ export class Booking extends BaseEntity { @Column({ name: 'pre_clearance_finalized_at', type: 'timestamptz', nullable: true }) preClearanceFinalizedAt?: Date | null; + /** + * Pre-declaration handshake: GL Ethiopia asks GL Djibouti who will handle this + * shipment in transit, Djibouti answers with a name (free text — the officer is + * not a platform user). The import declaration is blocked until `name` is set. + */ + @Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true }) + transitAssigneeRequestedAt?: Date | null; + + @Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true }) + transitAssigneeRequestNote?: string | null; + + @Column({ name: 'transit_assignee_name', type: 'text', nullable: true }) + transitAssigneeName?: string | null; + + @Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true }) + transitAssigneeAssignedAt?: Date | null; + /** GL staff user bound to this shipment by the station manager. */ @Column({ name: 'gl_assigned_staff_id', type: 'uuid', nullable: true }) glAssignedStaffId?: string | null; diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index 7b5cb77d6..3d8776e80 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -15,6 +15,9 @@ const generalImportBooking = { dutyRequired: true, roHoldReason: null, vesselDepartureDate: null, + // Djibouti already named the transit officer — the declaration gate is open. + transitAssigneeRequestedAt: new Date('2026-01-01T00:00:00Z'), + transitAssigneeName: 'Ahmed Bourhan', } as Booking; const generalExportBooking = { @@ -27,6 +30,8 @@ const generalExportBooking = { function makeService(overrides?: { booking?: Booking; workflowThrows?: boolean; + /** Resolve the input doc set with no required fields → every doc counts approved. */ + docsApproved?: boolean; }) { const booking = overrides?.booking ?? generalImportBooking; const bookingsRepository = { @@ -38,10 +43,14 @@ function makeService(overrides?: { }; const filesService = { upsertByCode: jest.fn().mockResolvedValue({}), + upload: jest.fn().mockResolvedValue({}), + deleteByCode: jest.fn().mockResolvedValue(undefined), findByResource: jest.fn().mockResolvedValue([]), }; const fileUploadSettingsService = { - getByCode: jest.fn().mockRejectedValue(new Error('no setting')), + getByCode: overrides?.docsApproved + ? jest.fn().mockResolvedValue({ fields: [] }) + : jest.fn().mockRejectedValue(new Error('no setting')), }; const workflowService = { assertPriorCompleteForBooking: overrides?.workflowThrows @@ -49,6 +58,7 @@ function makeService(overrides?: { : jest.fn().mockResolvedValue(undefined), completeMilestoneForBooking: jest.fn().mockResolvedValue(undefined), onDeclarationUploadedForBooking: jest.fn().mockResolvedValue(undefined), + onAllDocsApprovedForBooking: jest.fn().mockResolvedValue(undefined), onDutySkippedForBooking: jest.fn().mockResolvedValue(undefined), listMilestonesForBooking: jest.fn().mockResolvedValue([]), resolvePhaseForBooking: jest.fn().mockReturnValue(null), @@ -91,6 +101,8 @@ function makeService(overrides?: { documentQueried: jest.fn(), dutySlipUploadedToStaff: jest.fn(), clearanceDocsUploadedToStaff: jest.fn(), + transitAssigneeRequested: jest.fn(), + transitAssigneeAssigned: jest.fn(), } as never, // notifier ); @@ -186,6 +198,82 @@ describe('BookingClearanceService', () => { service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), ).rejects.toBeInstanceOf(BadRequestException); }); + + it('rejects an import declaration before Djibouti names the transit officer', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.uploadDeclaration('b-general', [{ fieldname: 'decl' } as Express.Multer.File]), + ).rejects.toThrow(/Request a transit assignee/i); + expect(workflowService.onDeclarationUploadedForBooking).not.toHaveBeenCalled(); + }); + + it('lets the export declaration through without a transit assignee', async () => { + const { service, workflowService } = makeService({ + docsApproved: true, + booking: { + ...generalExportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await service.uploadDeclaration('b-export', [ + { fieldname: 'decl' } as Express.Multer.File, + ]); + + expect(workflowService.onDeclarationUploadedForBooking).toHaveBeenCalled(); + }); + }); + + describe('transit assignee handshake', () => { + it('refuses an assignment GL Ethiopia never asked for', async () => { + const { service } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeRequestedAt: null, + transitAssigneeName: null, + } as Booking, + }); + + await expect( + service.assignTransitAssignee('b-general', 'Ahmed Bourhan'), + ).rejects.toThrow(/has not requested a transit assignee/i); + }); + + it('stamps the ask and then the name', async () => { + const { service, bookingsRepository } = makeService({ + booking: { + ...generalImportBooking, + transitAssigneeName: null, + } as Booking, + }); + + await service.requestTransitAssignee('b-general', ' night shift '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeRequestedAt: expect.any(Date), + transitAssigneeRequestNote: 'night shift', + }), + ); + + await service.assignTransitAssignee('b-general', ' Ahmed Bourhan '); + expect(bookingsRepository.update).toHaveBeenCalledWith( + 'b-general', + expect.objectContaining({ + transitAssigneeName: 'Ahmed Bourhan', + transitAssigneeAssignedAt: expect.any(Date), + }), + ); + }); }); describe('uploadReleaseOrder', () => { diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f0fc7abc..656ef20e4 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -71,6 +71,17 @@ export interface BookingClearanceView { roAmendmentRequestedAt?: string | null; operationReady?: boolean; preClearanceFinalized?: boolean; + /** + * Pre-declaration handshake with GL Djibouti: who handles this shipment in + * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot + * file the import customs declaration before it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; dutyAdvice?: { amount: number; currency: string; @@ -269,6 +280,16 @@ export class BookingClearanceService { : null, operationReady: boundary, preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt), + transitAssignee: { + requestedAt: booking.transitAssigneeRequestedAt + ? booking.transitAssigneeRequestedAt.toISOString() + : null, + requestNote: booking.transitAssigneeRequestNote ?? null, + name: booking.transitAssigneeName ?? null, + assignedAt: booking.transitAssigneeAssignedAt + ? booking.transitAssigneeAssignedAt.toISOString() + : null, + }, dutyAdvice, workflowFiles, t1, @@ -356,6 +377,53 @@ export class BookingClearanceService { ); } + /** + * GL Ethiopia asks Djibouti to name the officer who will handle this shipment + * in transit. The import declaration is gated on the answer, so this is the + * first thing ET does once the customer documents are approved. Re-requesting + * is allowed (a nudge) and simply restamps the ask. + */ + async requestTransitAssignee( + bookingId: string, + note: string | undefined, + ): Promise { + const booking = await this.loadBooking(bookingId); + + await this.bookingsRepository.update(bookingId, { + transitAssigneeRequestedAt: new Date(), + transitAssigneeRequestNote: note?.trim() || null, + } as never); + + this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null); + return this.bookingsService.findById(bookingId); + } + + /** + * GL Djibouti names the transit officer — free text, because the person is not + * a platform user. Answering unblocks the declaration for Ethiopia. A later + * call overwrites the name (reassignment) and re-notifies. + */ + async assignTransitAssignee(bookingId: string, assignee: string): Promise { + const booking = await this.loadBooking(bookingId); + if (!assignee?.trim()) { + throw new BadRequestException('Name the officer who will handle the transit.'); + } + if (!booking.transitAssigneeRequestedAt) { + throw new BadRequestException( + 'GL Ethiopia has not requested a transit assignee for this shipment yet.', + ); + } + + const previous = booking.transitAssigneeName ?? null; + await this.bookingsRepository.update(bookingId, { + transitAssigneeName: assignee.trim(), + transitAssigneeAssignedAt: new Date(), + } as never); + + this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous); + return this.bookingsService.findById(bookingId); + } + async uploadDeclaration( bookingId: string, files: Express.Multer.File[], @@ -369,6 +437,16 @@ export class BookingClearanceService { 'All required customer documents must be approved before uploading a declaration.', ); } + // Import only: the declaration is filed against whoever physically handles + // the shipment in Djibouti, so that name must be in first. Exports have no + // such handshake — their Djibouti steps come after the declaration. + if (tradeDirection === 'IMPORT' && !booking.transitAssigneeName) { + throw new BadRequestException( + booking.transitAssigneeRequestedAt + ? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.' + : 'Request a transit assignee from GL Djibouti before filing the customs declaration.', + ); + } const milestones = await this.workflowService.listMilestonesForBooking(bookingId); const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED'); if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') { 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 8f616d7a4..1a4de8245 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 @@ -225,6 +225,7 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', + submittedAt: new Date(), } as never); const updated = await this.contractsService.findById(contractId); this.notifier.submittedToStaff(updated); @@ -241,6 +242,7 @@ export class ContractTransitionService { await this.contractsRepository.update(contractId, { status: 'SUBMITTED', + submittedAt: new Date(), } as never); const updated = await this.contractsService.findById(contractId); this.notifier.submittedToStaff(updated); 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 ddd0ee837..cdf6b4ecf 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 @@ -216,6 +216,10 @@ export class Contract extends BaseEntity { @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) expiresAt?: Date | null; + /** When the customer last submitted this contract (DRAFT/CHANGES_REQUESTED → SUBMITTED). */ + @Column({ name: 'submitted_at', type: 'timestamptz', nullable: true }) + submittedAt?: Date | null; + @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx new file mode 100644 index 000000000..435baaf68 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx @@ -0,0 +1,220 @@ +import { useMemo } from "react"; +import { + BadgeCheck, + CalendarClock, + Flame, + Send, + ShieldCheck, + FileSignature, +} from "lucide-react"; +import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +import { + CONTRACT_APPROVAL_ROLE_LABELS, + HAZARDOUS_APPROVAL_ROLE_PERMISSION, +} from "@/lib/permissions"; + +interface ContractMilestonesTimelineProps { + contract: Freight.IContract; +} + +const SIGNATURE_ROLE_LABELS: Record = { + CUSTOMER: "Signed by customer", + STAFF: "Signed by EDR — line staff", + DIRECTOR: "Signed by EDR — director", + CEO: "Signed by EDR — CEO", +}; + +/** "27 Jul 2026, 18:18" — the exact stamp, shown in the tooltip. */ +function formatWhen(iso: string): string { + return new Date(iso).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +/** "3 hours ago" — the at-a-glance read. */ +function formatAgo(iso: string): string { + const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return "just now"; + const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [ + ["year", 31536000], + ["month", 2592000], + ["day", 86400], + ["hour", 3600], + ["minute", 60], + ]; + const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); + for (const [unit, secondsPerUnit] of units) { + if (seconds >= secondsPerUnit) { + return rtf.format(-Math.floor(seconds / secondsPerUnit), unit); + } + } + return "just now"; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" }); +} + +type MilestoneIcon = typeof Send; + +interface Milestone { + key: string; + at: string; + title: string; + detail?: string; + color: string; + icon: MilestoneIcon; +} + +/** + * The dated moments of a contract's life — submission, hazardous approval, + * final approval, both parties' signatures, full execution — read straight off + * the contract and its already-loaded approvalSteps/signatures (no extra + * fetch). Sits above the document edit history on the History tab. + */ +export function ContractMilestonesTimeline({ + contract, +}: ContractMilestonesTimelineProps) { + const milestones = useMemo(() => { + const items: Milestone[] = []; + + // A DRAFT/RENEWAL_DRAFT contract hasn't been (re)submitted yet — nothing + // to date. submittedAt is only tracked going forward; a contract that + // reached SUBMITTED before that column existed falls back to createdAt. + const submittedAt = + contract.submittedAt ?? + (contract.status !== "DRAFT" && contract.status !== "RENEWAL_DRAFT" + ? contract.createdAt + : null); + if (submittedAt) { + items.push({ + key: "submitted", + at: submittedAt, + title: "Submitted for review", + color: "blue", + icon: Send, + }); + } + + for (const step of contract.approvalSteps ?? []) { + if (!(step.requiredRole in HAZARDOUS_APPROVAL_ROLE_PERMISSION)) continue; + if (!step.actedAt) continue; + items.push({ + key: `hazard-${step.id}`, + at: step.actedAt, + title: CONTRACT_APPROVAL_ROLE_LABELS[step.requiredRole] ?? step.requiredRole, + detail: step.status === "REJECTED" ? "Rejected" : "Approved", + color: step.status === "REJECTED" ? "red" : "orange", + icon: Flame, + }); + } + + if (contract.contractGeneratedAt) { + items.push({ + key: "approved", + at: contract.contractGeneratedAt, + title: "Contract approved", + detail: "Every approval step cleared and the document was generated", + color: "edr-green", + icon: ShieldCheck, + }); + } + + for (const sig of contract.signatures ?? []) { + items.push({ + key: `signature-${sig.id}`, + at: sig.signedAt, + title: SIGNATURE_ROLE_LABELS[sig.role] ?? `Signed by ${sig.role}`, + detail: sig.signerDisplayName, + color: "grape", + icon: FileSignature, + }); + } + + if (contract.fullyExecutedAt) { + items.push({ + key: "executed", + at: contract.fullyExecutedAt, + title: "Fully executed", + detail: "Both parties have signed", + color: "edr-green", + icon: BadgeCheck, + }); + } + + return items.sort( + (a, b) => new Date(a.at).getTime() - new Date(b.at).getTime(), + ); + }, [contract]); + + const hasValidity = contract.contractValidFrom && contract.contractValidUntil; + + if (milestones.length === 0 && !hasValidity) { + return ( + + No dated milestones recorded yet. + + ); + } + + return ( + + {hasValidity && ( + + + + Valid {formatDate(contract.contractValidFrom!)} + {" → "} + {formatDate(contract.contractValidUntil!)} + + + )} + + {milestones.length > 0 && ( + + {milestones.map((m) => { + const Icon = m.icon; + return ( + } + color={m.color} + title={ + + + {m.title} + + + + {formatAgo(m.at)} + + + + } + > + {m.detail && ( + + {m.detail} + + )} + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 8917cb216..e79a4eb28 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -349,11 +349,11 @@ export function PhasedClearanceActionPanel({ API refuses the upload until the name is in. */} {showEt && canEt && - !isBooking && !clearance.transitAssignee?.name && !isMilestoneDone(clearance.milestones, "DECLARED") ? ( * different name later; the newest one wins and Ethiopia is notified again. */ export function TransitAssigneePanel({ - contractId, + entityId, + isBooking = false, transitAssignee, side, readOnly = false, @@ -59,9 +64,12 @@ export function TransitAssigneePanel({ const [note, setNote] = useState(""); const [assignee, setAssignee] = useState(transitAssignee?.name ?? ""); const [changing, setChanging] = useState(false); + const service = isBooking ? bookingsService : contractsService; const request = useMutation({ - mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()), + mutationFn: async () => { + await service.requestTransitAssignee(entityId, note.trim()); + }, onSuccess: () => { toast.success("Request sent to GL Djibouti"); setNote(""); @@ -70,8 +78,9 @@ export function TransitAssigneePanel({ }); const assign = useMutation({ - mutationFn: () => - contractsService.assignTransitAssignee(contractId, assignee.trim()), + mutationFn: async () => { + await service.assignTransitAssignee(entityId, assignee.trim()); + }, onSuccess: () => { toast.success("Transit assignee sent to GL Ethiopia"); setChanging(false); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 8e14a37d4..eaf3b180c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -144,6 +144,10 @@ export const URL_CONSTANTS = { CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`, CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`, + CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) => + `/bookings/${id}/clearance/transit-assignee/request`, + CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) => + `/bookings/${id}/clearance/transit-assignee/assign`, CLEARANCE_FINALIZE_PRE: (id: string) => `/bookings/${id}/clearance/finalize-pre-clearance`, CLEARANCE_TRANSIT_PERMIT: (id: string) => diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 7eedd36be..5ae66a51e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -16,6 +16,7 @@ import { Flame, History, LayoutGrid, + Milestone, Package, Receipt, RefreshCw, @@ -54,6 +55,7 @@ import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprov import { HazardDeclarationPanel } from "@/components/contracts/HazardDeclarationPanel"; import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel"; import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline"; +import { ContractMilestonesTimeline } from "@/components/contracts/ContractMilestonesTimeline"; import { ContractCustomerCard, ContractDocumentsCard, @@ -531,13 +533,22 @@ export default function ContractRequestDetailPage() { ) : null} ) : currentTab === "history" ? ( - - - + + + + + + + + ) : currentTab === "customer" ? ( ) : ( diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx index 1a9352348..22d3b3fca 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlClearanceDetailPage.tsx @@ -236,13 +236,15 @@ export default function GlClearanceDetailPage() { - {/* GL Ethiopia cannot file the customs declaration until this desk - names the officer handling the shipment in transit, so the ask - sits above everything else on the page. */} - {data.kind === "contract" ? ( + {/* GL Ethiopia cannot file the import customs declaration until this + desk names the officer handling the shipment in transit, so the + ask sits above everything else on the page. Exports have no such + gate — Djibouti's steps come after the declaration. */} + {isImport ? ( + postBooking(B.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id), { + note, + }), + + /** GL Djibouti names (or changes) that officer — unblocks the declaration. */ + assignTransitAssignee: (id: string, assignee: string) => + postBooking(B.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), { + assignee, + }), + uploadDeclaration: async ( id: string, files: Record, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 26d77a1c7..10359761e 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -752,6 +752,9 @@ export interface IContract extends BaseEntity { contractValidUntil?: string | null; expiresAt?: string | null; + /** When the customer last submitted this contract for review. */ + submittedAt?: string | null; + status: ContractStatus; /** * Body of the latest staff CHANGES_REQUESTED review note (detail response diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 15ea57a1a..21a52095b 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -783,6 +783,17 @@ export interface ClearanceView { /** Boundary milestone complete — customer may proceed to operations. */ operationReady?: boolean; preClearanceFinalized?: boolean; + /** + * Pre-declaration handshake with GL Djibouti: who handles this shipment in + * transit. `name` stays null until Djibouti answers, and GL Ethiopia cannot + * file the import customs declaration before it is set. + */ + transitAssignee?: { + requestedAt: string | null; + requestNote: string | null; + name: string | null; + assignedAt: string | null; + } | null; dutyAdvice?: { amount: number; currency: string;