diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts index e28dff30f..a0c82fa4d 100644 --- a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -12,7 +12,7 @@ * humanized handler name where a route has none. * * Excludes the AI Assist and Account entities. - * Generated from the controllers under src/ — 512 endpoints. + * Generated from the controllers under src/ — 517 endpoints. */ /** [title, method, entity] for one auditable route. */ export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; @@ -102,6 +102,9 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + // Chat + "POST /api/chat/sync": ["Re-run the chat room/membership reconcile immediately (normally nightly)", "POST", "Chat"], + // Company "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], @@ -129,6 +132,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], "PATCH /api/companies/identity/poa-declared": ["Answer whether anyone holds power of attorney for this company — the question that decides whose identity is verified.", "PATCH", "Company"], + "POST /api/companies/onboarding/revert-to-etrade": ["Drop the manual-registration route (co-operative or foreign investment licence): clear the typed registration and reopen onboarding so the TIN is verified against eTrade", "POST", "Company"], // Compliance "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], @@ -256,6 +260,7 @@ export const AUDIT_ENDPOINTS: Readonly> = { "POST /api/invoices/:id/eims/cancel": ["Cancel the invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/sales": ["Register a sales receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], "POST /api/invoices/:id/eims/receipt/withholding": ["Register a withholding receipt with MoR EIMS against a registered invoice", "POST", "EIMS Invoice"], + "POST /api/invoices/eims/bulk-cancel": ["Cancel multiple invoices", "POST", "EIMS Invoice"], // Exchange Setting "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], 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 67a0f930d..a2584559e 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -940,8 +940,8 @@ export class BookingsController { @Get('clearance/et-queue') @BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions) @ApiOperation({ summary: 'GL ET queue — general customs bookings awaiting ET action' }) - getBookingEtClearanceQueue() { - return this.bookingClearanceService.etQueue(); + getBookingEtClearanceQueue(@CurrentUser() user: unknown) { + return this.bookingClearanceService.etQueue(user); } @Get('clearance/dj-queue') 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 b29a4cfa5..dd87184fd 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 @@ -32,11 +32,14 @@ function makeService(overrides?: { workflowThrows?: boolean; /** Resolve the input doc set with no required fields → every doc counts approved. */ docsApproved?: boolean; + /** Yard ids the caller is scoped to; `null` (default) = unrestricted. */ + yardScope?: string[] | null; }) { const booking = overrides?.booking ?? generalImportBooking; const bookingsRepository = { findDocumentReviews: jest.fn().mockResolvedValue([]), update: jest.fn().mockResolvedValue(booking), + findByStatuses: jest.fn().mockResolvedValue([]), }; const bookingsService = { findById: jest.fn().mockResolvedValue(booking), @@ -111,6 +114,7 @@ function makeService(overrides?: { .mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }), } as never, // transit agents { findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository + { getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope ); return { @@ -124,6 +128,30 @@ function makeService(overrides?: { } describe('BookingClearanceService', () => { + describe('etQueue yard scope', () => { + const queueBookings = [ + { ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' }, + { ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' }, + { ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' }, + ] as unknown as Booking[]; + + it('keeps only bookings whose origin or destination is in scope', async () => { + const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] }); + bookingsRepository.findByStatuses.mockResolvedValue(queueBookings); + workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]); + const rows = await service.etQueue({}); + expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']); + }); + + it('shows everything when the position has no yard mapping', async () => { + const { service, bookingsRepository, workflowService } = makeService({ yardScope: null }); + bookingsRepository.findByStatuses.mockResolvedValue(queueBookings); + workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]); + const rows = await service.etQueue({}); + expect(rows).toHaveLength(3); + }); + }); + describe('adviseDuty', () => { it('skips duty milestones when duty is not required', async () => { const { service, workflowService, bookingsRepository } = makeService(); 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 43176da75..4b53f4dee 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 @@ -30,6 +30,7 @@ import { ClearanceMilestoneService } from './clearance-milestone.service'; import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; +import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { ContractsRepository } from './contracts.repository'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; @@ -158,6 +159,7 @@ export class BookingClearanceService { private readonly glExchangeService: GlExchangeService, private readonly transitAgentsService: TransitAgentsService, private readonly contractsRepository: ContractsRepository, + private readonly yardScope: YardScopeService, ) {} private async assertPhasedCustoms(booking: Booking): Promise { @@ -981,7 +983,7 @@ export class BookingClearanceService { return this.bookingsService.findById(bookingId); } - async etQueue(): Promise { + async etQueue(user?: unknown): Promise { const candidates = await this.bookingsRepository.findByStatuses([ ...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES, ]); @@ -991,7 +993,26 @@ export class BookingClearanceService { const milestones = await this.workflowService.listMilestonesForBooking(b.id); if (belongsOnEtClearanceQueue(milestones)) filtered.push(b); } - return this.attachContractSummary(filtered); + const rows = await this.attachContractSummary(filtered); + return this.narrowToYardScope(rows, user); + } + + /** + * Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's + * assigned yards (`freight.yard_positions` via the active position). Yards in + * the middle of a route do not count. An unmapped position, super admin or + * `yards:view_all` holder sees everything (scope resolves to `null`). + * Runs after {@link attachContractSummary} so route-fallback yards count too. + */ + private async narrowToYardScope(bookings: Booking[], user: unknown): Promise { + const scope = await this.yardScope.getScopedYardIds(user as never); + if (scope === null) return bookings; + const inScope = (id: string | null | undefined) => !!id && scope.includes(id); + return bookings.filter( + (b) => + inScope(b.originYardId ?? b.originYard?.id) || + inScope(b.destinationYardId ?? b.destinationYard?.id), + ); } /** diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index d0705bfb1..d743e3f0d 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -30,7 +30,8 @@ export class BookingRequestRepository extends BaseRepository { async findQueue(): Promise { return this.repository.find({ order: { createdAt: 'DESC' }, - relations: { contract: { company: true } }, + // `routes` rides along so the queue can be narrowed to the caller's yards. + relations: { contract: { company: true, routes: true } }, }); } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 1ca7cd84f..a2900174a 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import type { Freight } from '@edr/types'; +import { YardScopeService } from '../rule-engine/services/yard-scope.service'; import { BookingRequestRepository } from './booking-request.repository'; import { ContractsService } from './contracts.service'; import { ContractBookingService } from './contract-booking.service'; @@ -28,6 +29,7 @@ export class BookingRequestService { private readonly contractsService: ContractsService, private readonly contractBookingService: ContractBookingService, private readonly notifier: ContractNotifierService, + private readonly yardScope: YardScopeService, ) {} /** @@ -168,8 +170,25 @@ export class BookingRequestService { return request; } - queue(): Promise { - return this.repo.findQueue(); + /** + * GL queue narrowed to the caller's yards: a request stays when its route's + * ORIGIN or DESTINATION yard is one the caller's active position is mapped to + * (unmapped position / super admin → everything). A request with no + * resolvable route (no `contractRouteId` on a multi-route contract) has no + * yards to judge by and is kept visible. + */ + async queue(user?: unknown): Promise { + const rows = await this.repo.findQueue(); + const scope = await this.yardScope.getScopedYardIds(user as never); + if (scope === null) return rows; + return rows.filter((r) => { + const routes = r.contract?.routes ?? []; + const route = + routes.find((x) => x.id === r.contractRouteId) ?? + (routes.length === 1 ? routes[0] : undefined); + if (!route) return true; + return scope.includes(route.originYardId) || scope.includes(route.destinationYardId); + }); } private async findPending(requestId: string): Promise { diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 9f602ec21..1dc083934 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -123,8 +123,8 @@ export class ContractsController { @Get('booking-requests/queue') @BookingStaff(FREIGHT_PERMS.contracts.createBooking) @ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' }) - bookingRequestQueue() { - return this.bookingRequestService.queue(); + bookingRequestQueue(@CurrentUser() user: AuthUserPayload) { + return this.bookingRequestService.queue(user); } @Get('booking-requests/:reqId') diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 248092f95..277c3be38 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -42,6 +42,7 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.delete, FREIGHT_PERMS.trains.changeLocomotives, FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.changeWagonYard, FREIGHT_PERMS.trains.toggleActive, FREIGHT_PERMS.trains.disband, ]) @@ -107,6 +108,26 @@ export class TrainBuilderController { return this.trainBuilderService.setYard(id, dto.currentYardId); } + @Patch(':id/wagons/:wagonId/yard') + @FleetManage(FREIGHT_PERMS.trains.changeWagonYard) + @ApiOperation({ + summary: + 'Move one coupled wagon to another yard — refused while any live schedule has the wagon allocated', + }) + setWagonYard( + @Param('id', ParseUUIDPipe) id: string, + @Param('wagonId', ParseUUIDPipe) wagonId: string, + @Body() dto: UpdateTrainYardDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainBuilderService.setWagonYard( + id, + wagonId, + dto.currentYardId, + resolveAuthUserId(user), + ); + } + @Post(':id/wagons') @FleetManage(FREIGHT_PERMS.trains.assignWagons) @ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" }) diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 4b94b2dcb..827596bb7 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -514,6 +514,42 @@ export class TrainBuilderService { return this.getComposition(id); } + /** + * Move ONE coupled wagon to another yard (the train and the rest of the + * consist stay put). Refused while any live (DRAFT/SCHEDULED/DISPATCHED) + * schedule has the wagon allocated to a slot — its standing yard is part of + * that schedule's route validation. Ledger row mirrors `setYard`. + */ + async setWagonYard(id: string, wagonId: string, currentYardId: string, userId?: string | null) { + await this.dataSource.transaction(async (manager) => { + const train = await this.getEditableTrain(manager, id); + const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); + if (!wagon || wagon.trainId !== train.id) { + throw new NotFoundException(`Wagon ${wagonId} is not coupled to train ${train.code}`); + } + if (wagon.currentYardId === currentYardId) return; + const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); + if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is allocated to a scheduled or dispatched run; its yard cannot be changed`, + ); + } + await manager.getRepository(Wagon).update(wagon.id, { currentYardId: yard.id }); + await manager.getRepository(WagonMovement).save( + manager.getRepository(WagonMovement).create({ + wagonId: wagon.id, + fromYardId: wagon.currentYardId ?? null, + toYardId: yard.id, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + }); + return this.getComposition(id); + } + /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { @@ -541,11 +577,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, - ); - } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, @@ -582,11 +614,7 @@ export class TrainBuilderService { if (!wagon || wagon.trainId !== train.id) { throw new NotFoundException(`Wagon ${wagonId} is not part of this train`); } - if (await this.isWagonPinnedToLiveSchedule(manager, wagon.id)) { - throw new ConflictException( - `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, - ); - } + await this.assertDetachableAndReleaseStaleSlots(manager, wagon); const previousStatus = wagon.status; const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note); await manager.getRepository(Wagon).update(wagon.id, { @@ -669,6 +697,57 @@ export class TrainBuilderService { return rows.length > 0; } + /** + * Detach guard for removeWagon / sendWagonToMaintenance. A wagon is truly + * pinned only while a live schedule still NEEDS it: a slot carrying booking + * allocations, or any slot on a DISPATCHED run. An empty (allocation-free) + * slot on a DRAFT/SCHEDULED schedule is a stale reservation — its load was + * moved to another wagon (moveWagonLoad keeps the emptied slot) or its + * booking left through a path that didn't clean up — and used to pin the + * wagon forever. Release those slots here instead of blocking, with the + * same recount removeTrainSetWagonSlot does (wagonCount / totalLengthMeters + * feed the schedule capacity math). + */ + private async assertDetachableAndReleaseStaleSlots( + manager: EntityManager, + wagon: Wagon, + ): Promise { + const rows: { id: string; train_set_id: string; status: string; allocs: string }[] = + await manager.query( + `SELECT tsw.id, tsw.train_set_id, ts.status, + (SELECT count(*) + FROM freight.wagon_booking_allocations a + WHERE a.train_set_wagon_id = tsw.id + AND a.deleted_at IS NULL) AS allocs + FROM freight.train_set_wagons tsw + JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id + WHERE tsw.physical_wagon_id = $1 + AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED') + AND ts.deleted_at IS NULL + AND tsw.deleted_at IS NULL`, + [wagon.id], + ); + if (!rows.length) return; + if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) { + throw new ConflictException( + `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, + ); + } + await manager.getRepository(TrainSetWagon).delete(rows.map((r) => r.id)); + for (const trainSetId of [...new Set(rows.map((r) => r.train_set_id))]) { + const remaining = await manager.getRepository(TrainSetWagon).find({ + where: { trainSetId }, + select: { id: true, lengthMeters: true }, + }); + await manager.getRepository(TrainSet).update(trainSetId, { + wagonCount: remaining.length, + totalLengthMeters: round( + remaining.reduce((sum, w) => sum + (Number(w.lengthMeters) || 0), 0), + ), + }); + } + } + /** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */ async reorderWagons(id: string, dto: ReorderTrainWagonsDto) { await this.dataSource.transaction(async (manager) => { 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 1e7a56094..6efa2d78e 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -900,6 +900,11 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:disband", "Disband train", ), + perm( + "e1c00001-0001-4000-8000-000000000010", + "edr_freight_app:trains:change_wagon_yard", + "Change yard of a coupled wagon", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -2007,6 +2012,7 @@ export const FREIGHT_PERMS = { assignWagons: "edr_freight_app:trains:assign_wagons", changeLocomotives: "edr_freight_app:trains:change_locomotives", changeYard: "edr_freight_app:trains:change_yard", + changeWagonYard: "edr_freight_app:trains:change_wagon_yard", toggleActive: "edr_freight_app:trains:toggle_active", disband: "edr_freight_app:trains:disband", }, @@ -2343,6 +2349,7 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.trains.assignWagons, FREIGHT_PERMS.trains.changeLocomotives, FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.changeWagonYard, FREIGHT_PERMS.trains.toggleActive, FREIGHT_PERMS.trains.disband, FREIGHT_PERMS.routes.view, diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx index 101176042..d206fca83 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/ConsistWagonList.tsx @@ -6,11 +6,13 @@ import { type DraggableStateSnapshot, type DropResult, } from "@hello-pangea/dnd"; -import { ActionIcon, Badge, Box, Group, Stack, Text, Tooltip } from "@mantine/core"; +import { ActionIcon, Badge, Box, Group, Menu, Stack, Text, Tooltip } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; import { GripVertical, MapPin, Trash2, Wrench } from "lucide-react"; import { memo, useCallback, useMemo, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { api } from "@/services/api"; import type { TrainCompositionWagon } from "@/services/trainBuilder.service"; import { wagonTypeColor } from "./trainStatus"; @@ -38,6 +40,7 @@ function ConsistWagonList({ onReorder, onRemove, onMaintenance, + onChangeYard, busy = false, }: ConsistWagonListProps) { const onDragEnd = useCallback((result: DropResult) => { @@ -107,6 +110,7 @@ function ConsistWagonList({ busy={busy} onRemove={onRemove} onMaintenance={onMaintenance} + onChangeYard={onChangeYard} /> )} @@ -129,9 +133,68 @@ export interface ConsistWagonListProps { onRemove: (wagonId: string) => void; /** Detach the wagon and move it to MAINTENANCE status (page confirms first). */ onMaintenance: (wagon: TrainCompositionWagon) => void; + /** Move one wagon to another yard from its yard badge; absent = read-only badge. */ + onChangeYard?: (wagonId: string, currentYardId: string) => void; busy?: boolean; } +/** Yard badge that opens a yard picker when `onChange` is provided. */ +function WagonYardBadge({ + wagon, + busy, + onChange, +}: { + wagon: TrainCompositionWagon; + busy: boolean; + onChange?: (wagonId: string, currentYardId: string) => void; +}) { + const label = wagon.currentYard?.label ?? wagon.currentYard?.code ?? "No yard"; + const yardsQuery = useQuery( + api.routes.yards.queryOptions({ staleTime: 5 * 60_000, enabled: Boolean(onChange) }), + ); + if (!onChange) { + return wagon.currentYard ? ( + }> + {label} + + ) : null; + } + return ( + + + } + disabled={busy} + style={{ cursor: busy ? "default" : "pointer" }} + // Stop the drag handle from swallowing the click. + onMouseDown={(e) => e.stopPropagation()} + aria-label={`Change yard of wagon ${wagon.wagonNumber}`} + > + {label} + + + + Move wagon to yard + {(yardsQuery.data ?? []).map((y) => ( + onChange(wagon.id, y.id)} + > + {y.label ?? y.code} + + ))} + + + ); +} + const WagonRow = memo(function WagonRow({ wagon, index, @@ -141,6 +204,7 @@ const WagonRow = memo(function WagonRow({ busy, onRemove, onMaintenance, + onChangeYard, }: { wagon: TrainCompositionWagon; index: number; @@ -150,6 +214,7 @@ const WagonRow = memo(function WagonRow({ busy: boolean; onRemove: (wagonId: string) => void; onMaintenance: (wagon: TrainCompositionWagon) => void; + onChangeYard?: (wagonId: string, currentYardId: string) => void; }) { const color = wagonTypeColor(wagon.wagonType?.code); @@ -195,11 +260,7 @@ const WagonRow = memo(function WagonRow({ {wagon.wagonType.code} ) : null} - {wagon.currentYard ? ( - }> - {wagon.currentYard.label ?? wagon.currentYard.code} - - ) : null} + {wagon.wagonType diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index de43dff41..995137999 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -218,6 +218,8 @@ export const FREIGHT_PERMS = { /** Train-builder detail Actions menu — each item its own grant. */ changeLocomotives: "edr_freight_app:trains:change_locomotives", changeYard: "edr_freight_app:trains:change_yard", + /** Move ONE coupled wagon to another yard from the Wagon order list. */ + changeWagonYard: "edr_freight_app:trains:change_wagon_yard", toggleActive: "edr_freight_app:trains:toggle_active", disband: "edr_freight_app:trains:disband", }, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 69a4edb4b..48799cccf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -94,6 +94,7 @@ export default function TrainBuilderDetailPage() { const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons); const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives); const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard); + const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard); const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive); const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband); @@ -109,6 +110,7 @@ export default function TrainBuilderDetailPage() { ); const assignWagons = useMutation(api.trainBuilder.assignWagons.mutationOptions()); const removeWagon = useMutation(api.trainBuilder.removeWagon.mutationOptions()); + const setWagonYard = useMutation(api.trainBuilder.setWagonYard.mutationOptions()); const maintenanceWagon = useMutation( api.trainBuilder.sendWagonToMaintenance.mutationOptions(), ); @@ -162,6 +164,7 @@ export default function TrainBuilderDetailPage() { const busy = assignWagons.isPending || removeWagon.isPending || + setWagonYard.isPending || maintenanceWagon.isPending || reorderWagons.isPending; @@ -217,6 +220,16 @@ export default function TrainBuilderDetailPage() { }, [withToast, removeWagon.mutateAsync, trainId], ); + const handleChangeWagonYard = useCallback( + (wagonId: string, currentYardId: string) => { + if (!trainId) return; + void withToast( + () => setWagonYard.mutateAsync({ id: trainId, wagonId, currentYardId }), + "Could not change wagon yard", + ); + }, + [withToast, setWagonYard.mutateAsync, trainId], + ); const handleMaintenance = useCallback( (wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon), [], @@ -499,6 +512,9 @@ export default function TrainBuilderDetailPage() { onReorder={handleReorder} onRemove={handleRemove} onMaintenance={handleMaintenance} + onChangeYard={ + composition.editable && canChangeWagonYard ? handleChangeWagonYard : undefined + } /> diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 7770dbd96..57d9d56ca 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -2132,6 +2132,19 @@ export const api = { seedComposition, ), + setWagonYard: endpoint< + { id: string; wagonId: string; currentYardId: string }, + TrainComposition + >( + "train-builder", + "setWagonYard", + ({ id, wagonId, currentYardId }) => + trainBuilderService.setWagonYard(id, wagonId, currentYardId).then((r) => r.data), + undefined, + () => TRAIN_BUILDER_WAGON_INVALIDATIONS, + seedComposition, + ), + removeWagon: endpoint<{ id: string; wagonId: string }, TrainComposition>( "train-builder", "removeWagon", diff --git a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts index 5b245305e..3254051d3 100644 --- a/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/trainBuilder.service.ts @@ -315,6 +315,9 @@ export const trainBuilderService = { /** Relocate the train — coupled locomotives and wagons move with it. */ setYard: (id: string, currentYardId: string) => apiClient.patch(`${BASE}/${id}/yard`, { currentYardId }), + /** Move one coupled wagon to another yard; the train stays put. */ + setWagonYard: (id: string, wagonId: string, currentYardId: string) => + apiClient.patch(`${BASE}/${id}/wagons/${wagonId}/yard`, { currentYardId }), assignWagons: (id: string, wagonIds: string[]) => apiClient.post(`${BASE}/${id}/wagons`, { wagonIds }), removeWagon: (id: string, wagonId: string) => diff --git a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx index b6f4787d6..59e6f2ad6 100644 --- a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx @@ -1,139 +1,571 @@ +import { useEffect, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { ArrowRight, - BarChart3, + BellRing, + Briefcase, + Building2, + ChartColumn, + Check, CheckCircle2, Clock3, - Globe2, + FileCheck, + FileText, Mail, + Map as MapIcon, MapPin, Menu, + Package, + PackageSearch, Phone, - ShieldCheck, - Train, + Radar, + Receipt, + Route as RouteIcon, + Satellite, + Ship, + Timer, + TrainFront, + TrendingDown, + TrendingUp, Truck, - Users, } from "lucide-react"; +/* ------------------------------------------------------------------ */ +/* Motion helpers */ +/* ------------------------------------------------------------------ */ + +/** Reveals children once they scroll into view. No animation library needed. */ +function useReveal() { + const ref = useRef(null); + const [shown, setShown] = useState(false); + + useEffect(() => { + const node = ref.current; + if (!node) return; + if (typeof IntersectionObserver === "undefined") { + setShown(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShown(true); + observer.disconnect(); + } + }, + { rootMargin: "0px 0px -12% 0px", threshold: 0.15 }, + ); + + observer.observe(node); + return () => observer.disconnect(); + }, []); + + return { ref, shown }; +} + +function Reveal({ + children, + delay = 0, + className = "", +}: { + children: React.ReactNode; + delay?: number; + className?: string; +}) { + const { ref, shown } = useReveal(); + + return ( +
+ {children} +
+ ); +} + +/** Counts up to `value` when scrolled into view. Keeps prefix/suffix intact. */ +function CountUp({ + value, + decimals = 0, + prefix = "", + suffix = "", + duration = 1400, +}: { + value: number; + decimals?: number; + prefix?: string; + suffix?: string; + duration?: number; +}) { + const { ref, shown } = useReveal(); + const [display, setDisplay] = useState(0); + + useEffect(() => { + if (!shown) return; + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + setDisplay(value); + return; + } + + let frame = 0; + const start = performance.now(); + + const tick = (now: number) => { + const progress = Math.min(1, (now - start) / duration); + // easeOutCubic + setDisplay(value * (1 - Math.pow(1 - progress, 3))); + if (progress < 1) frame = requestAnimationFrame(tick); + }; + + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); + }, [shown, value, duration]); + + return ( + + {prefix} + {display.toFixed(decimals)} + {suffix} + + ); +} + +/* ------------------------------------------------------------------ */ +/* Content */ +/* ------------------------------------------------------------------ */ + +const navLinks = [ + { label: "Features", href: "#features" }, + { label: "Live ops", href: "#showcase" }, + { label: "Corridors", href: "#corridors" }, + { label: "How it works", href: "#how" }, + { label: "Contact", href: "#contact" }, +]; + +const heroTrust = [ + "Telebirr & CBE Birr payments", + "Fayda ID verified", + "Customs-ready documents", +]; + +const trackingStops = [ + { name: "Mojo Dry Port", time: "Loaded · 06:40", state: "done" }, + { name: "Adama", time: "Departed · 08:15", state: "done" }, + { name: "Dire Dawa", time: "ETA 14:30", state: "current" }, + { name: "Dewele (Border)", time: "ETA 18:05", state: "idle" }, + { name: "Djibouti Port", time: "ETA 22:40", state: "idle" }, +] as const; + const stats = [ { - label: "Active Corridors", - value: "24+", - icon: Globe2, + icon: RouteIcon, + value: , + label: "Addis Ababa → Djibouti · electrified", + trend: "Standard gauge", + down: false, + chart: [20, 24, 28, 30, 34, 36, 40, 44], }, { - label: "Monthly Shipments", - value: "12K+", - icon: Truck, + icon: Package, + value: , + label: "Monthly consignments booked", + trend: "+18% vs last month", + down: false, + chart: [16, 22, 19, 26, 30, 28, 36, 44], }, { - label: "Fleet Coverage", - value: "16 Trains", - icon: Train, + icon: TrainFront, + value: , + label: "Train sets in active rotation", + trend: "14 running now", + down: false, + fleet: true, }, { - label: "On-time Delivery", - value: "100%", - icon: Clock3, + icon: Timer, + value: , + label: "Booking to confirmed wagon", + trend: "-62% since launch", + down: true, + chart: [44, 40, 36, 30, 26, 20, 16, 12], }, ]; +const stations = [ + { name: "Sebeta", hub: false }, + { name: "Addis Ababa", hub: true, note: "Km 0" }, + { name: "Mojo", hub: true }, + { name: "Adama", hub: false }, + { name: "Awash", hub: false }, + { name: "Mieso", hub: false }, + { name: "Dire Dawa", hub: true }, + { name: "Dewele", hub: true, note: "Customs", border: true }, + { name: "Ali Sabieh", hub: false }, + { name: "Djibouti Port", hub: true, note: "Km 752" }, +]; + +const lanes = [ + { from: "Mojo Dry Port", to: "Djibouti Port", transit: "~16h", kind: "Containers" }, + { from: "Addis Ababa", to: "Djibouti Port", transit: "~19h", kind: "Mixed cargo" }, + { from: "Djibouti Port", to: "Mojo Dry Port", transit: "~16h", kind: "Imports" }, + { from: "Dire Dawa", to: "Djibouti Port", transit: "~8h", kind: "Bulk" }, +]; + +const steps = [ + { + num: "01", + title: "Verify & register", + body: "Sign up with your company TIN, verify with Fayda ID, and get trade access approved by EDR.", + }, + { + num: "02", + title: "Book & pay", + body: "Pick origin, destination, cargo type and schedule. Pay with Telebirr, CBE Birr or contract credit.", + }, + { + num: "03", + title: "Load & clear", + body: "Wagon allocation, loading confirmation, interchange document and customs stamps — all digital.", + }, + { + num: "04", + title: "Track & settle", + body: "Follow the train live, get gate notifications, and download the final invoice when the cargo is released.", + }, +]; + +const audiences = [ + { + icon: Building2, + title: "Shippers & importers", + body: "Book, pay, track and download documents from one dashboard.", + items: ["Consignment booking", "Live tracking", "Invoices & receipts"], + }, + { + icon: Ship, + title: "Shipping lines", + body: "Manage container inventory, bookings and interchange with the railway.", + items: ["Container management", "Booking completion", "Interchange documents"], + }, + { + icon: Briefcase, + title: "Transit agents", + body: "Handle customs declarations, stamps and signatures on behalf of clients.", + items: ["Digital stamps", "E-signatures", "Compliance checks"], + }, + { + icon: TrainFront, + title: "EDR operations", + body: "Train sets, locomotives, wagons, schedules, maintenance and incidents.", + items: ["Train builder", "Fleet & fuel", "Incident reporting"], + }, +]; + +/* ------------------------------------------------------------------ */ +/* Feature previews */ +/* ------------------------------------------------------------------ */ + +function Chip({ + children, + tone = "green", + icon: Icon, +}: { + children: React.ReactNode; + tone?: "green" | "amber" | "muted"; + icon?: React.ElementType; +}) { + const tones = { + green: "bg-emerald-50 text-emerald-700", + amber: "bg-amber-50 text-amber-700", + muted: "bg-slate-100 text-slate-500", + }; + + return ( + + {Icon ? : null} + {children} + + ); +} + +function BookingPreview() { + return ( +
+
+
+

CN-2026-08421

+

+ Mojo → Djibouti · 40ft × 8 +

+
+ Confirmed +
+ +
+ + Depart Thu 21 Aug · TS-14 + + ETB 184,200 +
+
+ ); +} + +function GpsPreview() { + return ( +
+
+ +
+ +
+ {["Mojo", "Adama", "Awash", "Dire Dawa", "Djibouti"].map((s) => ( + {s} + ))} +
+ +
+ Loco 3421 · 64 km/h + + ETA 14:30 + +
+
+ ); +} + +function DocsPreview() { + const docs = [ + { name: "Interchange document", status: "Signed" }, + { name: "Customs declaration", status: "Stamped" }, + { name: "Commercial invoice", status: "Pending" }, + ]; + + return ( +
+ {docs.map((doc) => { + const pending = doc.status === "Pending"; + + return ( +
+ + {doc.name} + + {doc.status} + +
+ ); + })} +
+ ); +} + +function BillingPreview() { + return ( +
+
+
+

INV-1042 · Aug

+

+ ETB 1,284,600 +

+
+ Paid +
+ +
+ {["Telebirr", "CBE Birr", "eBirr", "Card"].map((method) => ( + + {method} + + ))} +
+
+ ); +} + +function MilePreview() { + const legs = [ + { icon: Truck, name: "Truck" }, + { icon: TrainFront, name: "Rail" }, + { icon: Truck, name: "Truck" }, + ]; + + return ( +
+
+ {legs.map((leg, index) => { + const Icon = leg.icon; + const isRail = index === 1; + + return ( +
+
+ + + + {leg.name} +
+ + {index < legs.length - 1 ? ( + + ) : null} +
+ ); + })} +
+ +
+ Warehouse, Gerji + Consignee, Djibouti +
+
+ ); +} + +function AnalyticsPreview() { + const bars = [22, 30, 26, 36, 40, 34, 46, 52, 48, 56]; + + return ( +
+
+ {bars.map((height, index) => ( + = 8 ? "bg-edr-primary" : "bg-emerald-200" + }`} + style={{ + height: `${height}%`, + animationDelay: `${index * 60}ms`, + }} + /> + ))} +
+ +
+ + Corridor throughput · TEU/week + + +12% +
+
+ ); +} + const features = [ { - title: "Real-time Shipment Tracking", - description: - "Track consignments across Addis Ababa, Dire Dawa, Djibouti, Mojo, and all major freight corridors.", + icon: PackageSearch, + title: "Consignment booking", + body: "Book containers or bulk cargo, pick a train schedule, and get a confirmed wagon allocation in minutes.", + preview: , + }, + { + icon: Radar, + title: "Live GPS tracking", + body: "Locomotive telemetry and wagon-level position across every station from Mojo to Djibouti Port.", + preview: , + }, + { + icon: FileCheck, + title: "Customs & documents", + body: "Interchange documents, stamps and e-signatures generated and shared with transit agents automatically.", + preview: , + }, + { + icon: Receipt, + title: "Billing & payments", + body: "Contract rates, invoices and settlement via Telebirr, CBE Birr, eBirr or card — with full audit trail.", + preview: , + }, + { icon: Truck, + title: "First & last mile", + body: "Request trucking to and from the railhead and see it on the same timeline as the rail leg.", + preview: , }, { - title: "Rail Freight Operations", - description: - "Monitor train schedules, operational performance, maintenance, and corridor activity.", - icon: Train, - }, - { - title: "Smart Analytics Dashboard", - description: - "Visualize operational insights, shipment trends, and corridor performance in real time.", - icon: BarChart3, - }, - { - title: "Enterprise-grade Security", - description: - "Secure portal access, billing workflows, document management, and customer operations.", - icon: ShieldCheck, + icon: ChartColumn, + title: "Operations analytics", + body: "Corridor throughput, on-time performance, fleet utilisation and incident reporting for operators.", + preview: , }, ]; -const corridors = [ - "Addis Ababa → Djibouti", - "Dire Dawa → Djibouti", - "Adama → Dire Dawa", - "Mojo → Djibouti", - "Awash → Holhol", - "Mieso → Aysha", -]; +/* ------------------------------------------------------------------ */ +/* Page */ +/* ------------------------------------------------------------------ */ export default function EDRFreightLandingPage() { return (
+ + {/* Navbar */} -
+
-
-
- -
+ + + + -
-

EDR Freight

- -

+ + EDR Freight + Rail Logistics Platform -

-
-
+ + + -
{/* Hero */} -
-
+
+
+
-
+
-
- - Ethiopia–Djibouti Railway Freight Platform -
+ + + + Live on the Addis Ababa – Djibouti corridor + + -

- Smarter Railway Freight Logistics For Modern Operations -

+ +

+ Move cargo by rail. + Track every wagon. +

+
-

- EDR Freight enables logistics companies and railway operators to - manage shipments, monitor freight corridors, optimize train - operations, and streamline enterprise logistics workflows. -

+ +

+ EDR Freight is the booking, tracking and billing platform for the + Ethio-Djibouti Railway. Book consignments, schedule train sets, + clear customs documents and settle invoices — in one portal. +

+
-
- - Get Started - - - - - Login - -
- -
- {[ - "Real-time Tracking", - "Railway Analytics", - "Secure Operations", - "Multi-corridor Freight", - ].map((item) => ( -
+
+ - - {item} -
- ))} -
+ Book a shipment + + + + + See how it works + +
+ + + +
+ {heroTrust.map((item) => ( + + + {item} + + ))} +
+
- {/* Hero Dashboard Card */} -
-
-
-
-

- Freight Operations + {/* Live tracking card */} + +

+
+
+

CN-2026-08417

+

+ Mojo Dry Port → Djibouti Port · 40ft × 12

- -

Live Statistics

-
- -
+ + + In transit +
-
- {stats.map((item) => { - const Icon = item.icon; +
    + {trackingStops.map((stop, index) => { + const isLast = index === trackingStops.length - 1; + const done = stop.state === "done"; + const current = stop.state === "current"; return ( -
    -
    - -
    +
  1. + + + {!isLast ? ( + + ) : null} + -

    {item.value}

    - -

    - {item.label} -

    -
  2. + + + {stop.name} + + + {stop.time} + + + ); })} -
+ -
-
-
-

- Corridor Performance +

+ {[ + ["Train", "TS-14 · 2 locos"], + ["Wagons", "12 / 12 loaded"], + ["Next gate", "Dewele customs"], + ].map(([key, value]) => ( +
+

+ {key}

- -

- 99% -

+

{value}

- -
- Operational -
-
- -
-
-
+ ))}
+ +
+
-
-
-
- -
+ {/* Stats */} +
+
+ {stats.map((stat, index) => { + const Icon = stat.icon; + const TrendIcon = stat.down ? TrendingDown : TrendingUp; -
-

16 Trains Active

+ return ( + +
+
+ + + -

- Across all freight corridors + {stat.trend} +

+ +

+ {stat.value}

+

+ {stat.label} +

+ + {stat.fleet ? ( +
+ {Array.from({ length: 16 }).map((_, unit) => ( + + ))} +
+ ) : ( +
+ {stat.chart!.map((height, bar) => ( + = stat.chart!.length - 3 + ? "bg-edr-primary" + : "bg-emerald-200" + }`} + style={{ + height: `${(height / 48) * 100}%`, + animationDelay: `${bar * 60}ms`, + }} + /> + ))} +
+ )}
-
-
-
+ + ); + })}
{/* Features */} -
-
-
-
- Platform Features -
- -

- Everything needed for freight operations -

- -

- Centralized railway freight operations with live shipment - visibility, operational monitoring, customer management, and - intelligent logistics insights. +

+
+ +

+ PLATFORM

-
+

+ Everything between the dry port and the quay +

+ -
- {features.map((feature) => { - const Icon = feature.icon; + +

+ One portal for shippers, shipping lines, transit agents and railway + operations. Built around the real workflow of the corridor, not a + generic TMS. +

+
+
- return ( -
-
- +
+ {features.map((feature, index) => { + const Icon = feature.icon; + + return ( + +
+
+ {feature.preview}
-

{feature.title}

+
+
+ + + +

{feature.title}

+
-

- {feature.description} -

-
- ); - })} -
+

+ {feature.body} +

+ + + Learn more + + +
+ + + ); + })} +
+
+ + {/* Showcase */} +
+
+ +
+ Freight train hauling containers along the corridor + +
+ +
+ + + TS-14 · Awash → Mieso · 68 km/h + +
+ +
+ {[ + ["98.4%", "On-time"], + ["312", "Wagons moving"], + ["16h 20m", "Avg transit"], + ].map(([value, key]) => ( +
+

{value}

+

{key}

+
+ ))} +
+
+ + + +

+ LIVE OPERATIONS +

+

+ See the whole train, not just a tracking number +

+

+ GPS from every locomotive, wagon-level loading status, gate events at + Dewele customs and ETA that updates as the train moves. Shippers and + operators look at the same map. +

+ +
    + {[ + { icon: Satellite, text: "Locomotive telemetry every 30 seconds" }, + { icon: BellRing, text: "SMS & in-app alerts on gate and border events" }, + { icon: MapIcon, text: "Shareable tracking link for your consignee" }, + ].map((point) => { + const Icon = point.icon; + + return ( +
  • + + + + {point.text} +
  • + ); + })} +
+
{/* Corridors */} -
+
-
-
-
- Freight Corridors -
+ +

+ NETWORK +

+

+ One line. Two countries. Every station on the timeline. +

+

+ Standard-gauge, fully electrified. Book any origin–destination pair + along the corridor and see wagon position station by station. +

+
-

- Connected logistics infrastructure -

+ {/* Animated corridor */} + +
+ + {stations.map((station) => ( + + ))} +
-

- Efficiently move freight across strategic Ethiopia–Djibouti - railway corridors with operational visibility and optimized - transport coordination. -

- -
- {corridors.map((corridor) => ( -
+ {stations.map((station) => ( + + -
- - {corridor} -
- ))} -
+ {station.name} + + {station.note ? ( + + {station.note} + + ) : null} + + ))}
+
-
-
-
-

- Operational Insights +

+ {lanes.map((lane, index) => ( + +
+

+ {lane.from} + + {lane.to} +

+

+ {lane.transit} transit · {lane.kind}

- -

- Freight Performance -

- -
- -
-
- -
- {[ - { - label: "Bookings Processed", - value: "9,842", - progress: "92%", - }, - { - label: "On-time Shipments", - value: "99%", - progress: "99%", - }, - { - label: "Customer Satisfaction", - value: "99%", - progress: "99%", - }, - ].map((item) => ( -
-
- {item.label} - - - {item.value} - -
- -
-
-
-
- ))} -
- -
-
-
- -
- -
-

- Enterprise-ready Platform -

- -

- Designed for large-scale freight and railway operations. -

-
-
-
-
+ + ))}
+ {/* How it works */} +
+
+ +

+ HOW IT WORKS +

+

+ From booking to delivered in four steps +

+

+ No phone calls, no paper manifests. Every party — shipper, shipping + line, transit agent and station — works from the same record. +

+ + + Create a free account + + +
+ +
+ {steps.map((step, index) => ( + +
+ + {step.num} + + +
+

{step.title}

+

+ {step.body} +

+
+
+
+ ))} +
+
+
+ + {/* Audiences */} +
+ +

+ Built for everyone on the corridor +

+
+ +
+ {audiences.map((audience, index) => { + const Icon = audience.icon; + + return ( + +
+ +

{audience.title}

+

+ {audience.body} +

+ +
    + {audience.items.map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ ); + })} +
+
+ {/* Contact */} -
-
-
-
-
- Contact Us -
+
+
+ +

+ CONTACT +

+

+ Let’s move freight smarter +

+

+ Contact EDR Freight for partnership opportunities, enterprise + onboarding, or logistics support. +

-

- Let’s move freight smarter -

+
+ {[ + { icon: Mail, label: "Email", value: "support@edrfreight.com" }, + { icon: Phone, label: "Phone", value: "+251 11 000 0000" }, + { icon: MapPin, label: "Head Office", value: "Addis Ababa, Ethiopia" }, + ].map((row) => { + const Icon = row.icon; -

- Contact EDR Freight for partnership opportunities, enterprise - onboarding, or logistics support. -

- -
-
-
- + return ( +
+ + + +
+

{row.label}

+

{row.value}

+
- -
-

Email

-

- support@edrfreight.com -

-
-
- -
-
- -
- -
-

Phone

-

+251 11 000 0000

-
-
- -
-
- -
- -
-

Head Office

-

- Addis Ababa, Ethiopia -

-
-
-
+ ); + })}
+ - {/* Contact Form */} -
+ +

Send us a message

-

We’ll get back to you as soon as possible.

@@ -504,117 +1129,359 @@ export default function EDRFreightLandingPage() {