diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 20ee4aeab..3b80db1bf 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -26,6 +26,7 @@ "@tria-plc/iamapi-common": "^0.1.6", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", + "axios": "^1.7.7", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "dotenv": "^17.4.2", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1a356cc82..8a95640c5 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -17,6 +17,8 @@ import { BillingModule } from "./modules/billing/billing.module"; import { NotificationsModule } from "./modules/notifications/notifications.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; +import { OtpModule } from './modules/otp/otp.module'; +import { DropdownSettingsService } from "./modules/dropdown-settings/dropdown-settings.service"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { EdrOrgSeeder } from "./seed/edr-org.seeder"; @@ -44,6 +46,7 @@ import { EdrOrgSeeder } from "./seed/edr-org.seeder"; NotificationsModule, FileUploadSettingsModule, DropdownSettingsModule, + OtpModule, BackofficeModule, ], providers: [EdrOrgSeeder], diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 85b470453..03b5a5549 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -1,5 +1,6 @@ +// src/modules/customers/customers.controller.ts + import { - Body, Controller, Delete, Get, @@ -9,49 +10,75 @@ import { ParseUUIDPipe, Patch, Post, + Body, + Query, } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { ApiOperation } from "@nestjs/swagger"; import { CustomersService } from "./customers.service"; import { CreateCustomerDto } from "./dto/create-customer.dto"; import { UpdateCustomerDto } from "./dto/update-customer.dto"; +import { Customer } from "./entities/customer.entity"; -@ApiTags("customers") @Controller("customers") export class CustomersController { constructor(private readonly customersService: CustomersService) {} @Post() - @ApiOperation({ summary: "Create a new customer" }) - create(@Body() dto: CreateCustomerDto) { - return this.customersService.create(dto); + create(@Body() createCustomerDto: CreateCustomerDto): Promise { + return this.customersService.create(createCustomerDto); } @Get() - @ApiOperation({ summary: "List all customers" }) - findAll() { + findAll(): Promise { return this.customersService.findAll(); } + @Get("stats") + @ApiOperation({ summary: "Get customer statistics" }) + getStats(): Promise<{ total: number; withVatNumber: number }> { + return this.customersService.getStats(); + } + + @Get("search") + searchByName(@Query("name") name: string): Promise { + return this.customersService.searchByName(name); + } + + @Get("email/:email") + findByEmail(@Param("email") email: string): Promise { + return this.customersService.findByEmail(email); + } + + @Get("vat/:vatNumber") + findByVatNumber(@Param("vatNumber") vatNumber: string): Promise { + return this.customersService.findByVatNumber(vatNumber); + } + @Get(":id") - @ApiOperation({ summary: "Get a customer by ID" }) - findOne(@Param("id", ParseUUIDPipe) id: string) { + findById(@Param("id", ParseUUIDPipe) id: string): Promise { return this.customersService.findById(id); } + @Get("user/:userId") + findByUserId(@Param("userId", ParseUUIDPipe) userId: string): Promise { + return this.customersService.findByUserId(userId); + } + @Patch(":id") @ApiOperation({ summary: "Update a customer" }) update( @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCustomerDto, - ) { + ): Promise { return this.customersService.update(id, dto); } @Delete(":id") @ApiOperation({ summary: "Soft-delete a customer" }) @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param("id", ParseUUIDPipe) id: string) { - return this.customersService.remove(id); + remove(@Param("id", ParseUUIDPipe) id: string): Promise { + return this.customersService.delete(id); } -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts index c6cb72fcf..83a64b9da 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.repository.ts @@ -1,21 +1,117 @@ -import { BaseRepository } from "@edr/api-common"; +// import { BaseRepository } from "@edr/api-common"; +// import { EntityRepository } from "typeorm"; + +// src/modules/customers/customers.repository.ts import { Injectable } from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; - +import { Repository, FindManyOptions, FindOptionsWhere } from "typeorm"; import { Customer } from "./entities/customer.entity"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +// import { UpdateCustomerDto } from "./dto/update-customer.dto"; @Injectable() -export class CustomersRepository extends BaseRepository { +export class CustomersRepository { constructor( @InjectRepository(Customer) - repository: Repository, - ) { - super(repository); + private readonly repository: Repository, + ) { } + + async create(dto: CreateCustomerDto): Promise { + const customer = this.repository.create(dto); + return await this.repository.save(customer); } - /** Find a customer by their unique email. */ - findByEmail(email: string): Promise { - return this.repository.findOne({ where: { email } }); + async findAll(options?: FindManyOptions): Promise { + return await this.repository.find(options); } -} + + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id } as FindOptionsWhere }); + } + + async findByUserId(userId: string): Promise { + return await this.repository.findOne({ where: { userId } as FindOptionsWhere }); + } + + async findByEmail(email: string): Promise { + return await this.repository.findOne({ where: { email } as FindOptionsWhere }); + } + + async findByVatNumber(vatNumber: string): Promise { + return await this.repository.findOne({ where: { vatNumber } as FindOptionsWhere }); + } + + async findByName(name: string): Promise { + return await this.repository + .createQueryBuilder("customer") + .where("customer.name ILIKE :name", { name: `%${name}%` }) + .getMany(); + } + + async findOneByEmailOrVat(email?: string, vatNumber?: string): Promise { + if (!email && !vatNumber) return null; + + const queryBuilder = this.repository.createQueryBuilder('customer'); + + if (email && vatNumber) { + queryBuilder.where('customer.email = :email', { email }) + .orWhere('customer.vatNumber = :vatNumber', { vatNumber }); + } else if (email) { + queryBuilder.where('customer.email = :email', { email }); + } else if (vatNumber) { + queryBuilder.where('customer.vatNumber = :vatNumber', { vatNumber }); + } + + return await queryBuilder.getOne(); + } + + async update(id: string, updates: Partial): Promise { + await this.repository.update(id, updates); + return this.findById(id); + } + + async delete(id: string): Promise { + const result = await this.repository.delete(id); + return (result.affected ?? 0) > 0; + } + + async count(where?: any): Promise { + if (where?.createdAt) { + const result = await this.repository + .createQueryBuilder('customer') + .where('customer.createdAt >= :date', { date: where.createdAt }) + .getCount(); + return result; + } + return await this.repository.count(); + } + + async existsByUniqueFields(email: string, vatNumber?: string): Promise { + const queryBuilder = this.repository.createQueryBuilder('customer') + .where('customer.email = :email', { email }); + + if (vatNumber) { + queryBuilder.orWhere('customer.vatNumber = :vatNumber', { vatNumber }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } + + async countWithVatNumber(): Promise { + const count = await this.repository + .createQueryBuilder('customer') + .where('customer.vatNumber IS NOT NULL') + .andWhere("customer.vatNumber != ''") + .getCount(); + + return count; + } + + getRepository(): Repository { + return this.repository; + } + softDelete(id: string): any { + return id; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.service.ts b/apps/edr-freight-api/src/modules/customers/customers.service.ts index 6394e1ad9..7a5238439 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.service.ts @@ -1,7 +1,8 @@ import { - ConflictException, Injectable, NotFoundException, + ConflictException, + BadRequestException, } from "@nestjs/common"; import { CustomersRepository } from "./customers.repository"; @@ -13,34 +14,97 @@ import { Customer } from "./entities/customer.entity"; export class CustomersService { constructor(private readonly customersRepository: CustomersRepository) {} + /** Create a new customer */ async create(dto: CreateCustomerDto): Promise { - const existing = await this.customersRepository.findByEmail(dto.email); - if (existing) { + const exists = await this.customersRepository.existsByUniqueFields( + dto.email, + dto.vatNumber, + ); + + if (exists) { throw new ConflictException( - `Customer with email "${dto.email}" already exists`, + "Customer with same email or VAT number already exists", ); } + + if (dto.vatNumber && dto.vatNumber.length !== 10) { + throw new BadRequestException("VAT number must be exactly 10 digits"); + } + return this.customersRepository.create(dto); } + /** Get all customers */ findAll(): Promise { - return this.customersRepository.findAll({ order: { name: "ASC" } }); + return this.customersRepository.findAll({ + order: { companyName: "ASC" }, + }); } + /** Get customer by ID */ async findById(id: string): Promise { const customer = await this.customersRepository.findById(id); + if (!customer) { - throw new NotFoundException(`Customer ${id} not found`); + throw new NotFoundException(`Customer with ID ${id} not found`); } + return customer; } + async findByUserId(userId: string): Promise { + const customer = await this.customersRepository.findByUserId(userId); + + if (!customer) { + throw new NotFoundException(`Customer with ID ${userId} not found`); + } + + return customer; + } + + /** Get customer by email */ + async findByEmail(email: string): Promise { + const customer = await this.customersRepository.findByEmail(email); + + if (!customer) { + throw new NotFoundException(`Customer with email ${email} not found`); + } + + return customer; + } + + /** Get customer by VAT number */ + async findByVatNumber(vatNumber: string): Promise { + const customer = await this.customersRepository.findByVatNumber(vatNumber); + + if (!customer) { + throw new NotFoundException( + `Customer with VAT number ${vatNumber} not found`, + ); + } + + return customer; + } + + /** Search customers by name */ + searchByName(name: string): Promise { + return this.customersRepository.findByName(name); + } + + /** Update customer */ async update(id: string, dto: UpdateCustomerDto): Promise { await this.findById(id); + // Validate VAT number if provided + if (dto.vatNumber && dto.vatNumber.length !== 10) { + throw new BadRequestException("VAT number must be exactly 10 digits"); + } + + // Check email conflict if (dto.email) { - const conflict = await this.customersRepository.findByEmail(dto.email); - if (conflict && conflict.id !== id) { + const existing = await this.customersRepository.findByEmail(dto.email); + + if (existing && existing.userId !== id) { throw new ConflictException( `Customer with email "${dto.email}" already exists`, ); @@ -48,14 +112,29 @@ export class CustomersService { } const updated = await this.customersRepository.update(id, dto); + if (!updated) { throw new NotFoundException(`Customer ${id} not found`); } + return updated; } + /** Delete customer (soft delete) */ async remove(id: string): Promise { await this.findById(id); await this.customersRepository.softDelete(id); } -} + + /** Get customer statistics */ + async getStats(): Promise<{ total: number; withVatNumber: number }> { + const total = await this.customersRepository.count(); + const withVatNumber = await this.customersRepository.countWithVatNumber(); + + return { total, withVatNumber }; + } + + delete(id: string): any { + return id; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts index 854b3eaf1..39fc16414 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts @@ -4,8 +4,12 @@ import { IsOptional, IsString, MaxLength, + IsNotEmpty, + Length, + Matches, } from "class-validator"; +// Enums export enum CustomerStatusDto { Active = "Active", Pending = "Pending", @@ -18,23 +22,57 @@ export enum CustomerTypeDto { Supplier = "Supplier", } +// DTO export class CreateCustomerDto { + // Basic identity @IsString() - @MaxLength(256) - name!: string; + @IsNotEmpty() + userId!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + firstName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + lastName!: string; @IsEmail() + @IsNotEmpty() email!: string; @IsString() - @MaxLength(32) + @IsNotEmpty() + @MaxLength(20) phone!: string; - @IsOptional() + // Company info @IsString() - @MaxLength(256) - company?: string; + @IsNotEmpty() + @MaxLength(200) + companyName!: string; + @IsEmail() + @IsNotEmpty() + companyEmail!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + companyPhone!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(100) + companyLocation!: string; + + @IsString() + @IsNotEmpty() + companyAddress!: string; + + // Classification @IsOptional() @IsEnum(CustomerTypeDto) customerType?: CustomerTypeDto; @@ -43,31 +81,76 @@ export class CreateCustomerDto { @IsEnum(CustomerStatusDto) status?: CustomerStatusDto; + // Legal identifiers + @IsString() + @IsNotEmpty() + @Length(10, 10) + @Matches(/^\d+$/, { message: "TIN must contain only digits" }) + tinNumber!: string; + + @IsString() + @IsNotEmpty() + @Length(16, 16) + @Matches(/^\d+$/, { message: "FAN must contain only digits" }) + fanNumber!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(50) + vatNumber!: string; + + // Contact person + @IsString() + @IsNotEmpty() + @MaxLength(100) + contactPersonName!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + contactPersonPhone!: string; + + // Management + @IsString() + @IsNotEmpty() + @MaxLength(100) + generalManagerName!: string; + + @IsEmail() + @IsNotEmpty() + generalManagerEmail!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(20) + generalManagerPhone!: string; + + // POA (Power of Attorney) @IsOptional() @IsString() - @MaxLength(64) - tinNumber?: string; + @MaxLength(100) + poaName?: string; @IsOptional() @IsString() - @MaxLength(128) - city?: string; + @MaxLength(20) + poaPhone?: string; @IsOptional() @IsString() - @MaxLength(128) - country?: string; + poaAddress?: string; + + @IsOptional() + @IsEmail() + poaEmail?: string; @IsOptional() @IsString() - address?: string; - - @IsOptional() - @IsString() - @MaxLength(64) - taxId?: string; + @MaxLength(100) + poaLocation?: string; + // Extra @IsOptional() @IsString() notes?: string; -} +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts new file mode 100644 index 000000000..d6e9b9e17 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts @@ -0,0 +1,60 @@ +// src/modules/customers/dto/response-customer.dto.ts +import { Customer } from '../entities/customer.entity'; + +export class ResponseCustomerDto { + UserId: string; + firstName: string; + lastName: string; + email: string; + phone: string; + companyName: string; + companyEmail: string; + companyPhone: string; + companyLocation: string; + companyAddress: string; + contactPersonName: string; + contactPersonPhone: string; + tinNumber: string; + vatNumber?: string; + fanNumber: string; + generalManagerName: string; + generalManagerEmail: string; + generalManagerPhone: string; + poaName?: string; + poaPhone?: string; + poaAddress?: string; + poaEmail?: string; + poaLocation?: string; + notes?: string; + createdAt: Date; + updatedAt: Date; + + constructor(customer: Customer) { + this.UserId = customer.userId; + this.firstName = customer.firstName; + this.lastName = customer.lastName; + this.email = customer.email; + this.phone = customer.phone; + this.companyName = customer.companyName; + this.companyEmail = customer.companyEmail; + this.companyPhone = customer.companyPhone; + this.companyLocation = customer.companyLocation; + this.companyAddress = customer.companyAddress; + this.contactPersonName = customer.contactPersonName; + this.contactPersonPhone = customer.contactPersonPhone; + this.tinNumber = customer.tinNumber; + this.vatNumber = customer.vatNumber; + this.fanNumber = customer.fanNumber; + this.generalManagerName = customer.generalManagerName; + this.generalManagerEmail = customer.generalManagerEmail; + this.generalManagerPhone = customer.generalManagerPhone; + this.poaName = customer.poaName ?? ''; + this.poaPhone = customer.poaPhone ?? ''; + this.poaAddress = customer.poaAddress ?? ''; + this.poaEmail = customer.poaEmail ?? ''; + this.poaLocation = customer.poaLocation ?? ''; + this.notes = customer.notes ?? ''; + this.createdAt = customer.createdAt; + this.updatedAt = customer.updatedAt; + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts index 94b49d0fd..3651f4b44 100644 --- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts +++ b/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts @@ -1,5 +1,9 @@ -import { PartialType } from "@nestjs/swagger"; +// src/modules/customers/dto/update-customer.dto.ts +import { PartialType } from '@nestjs/swagger'; +import { CreateCustomerDto } from './create-customer.dto'; -import { CreateCustomerDto } from "./create-customer.dto"; - -export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {} +export class UpdateCustomerDto extends PartialType(CreateCustomerDto) { + email?: string; + vatNumber?: string; + // Add any other properties you need to access directly +} diff --git a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts index 98a248c97..80041432e 100644 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ b/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts @@ -1,54 +1,100 @@ -import { BaseEntity } from "@edr/api-common"; -import { Column, Entity } from "typeorm"; +import { + Column, + Entity, + CreateDateColumn, + UpdateDateColumn, + Index, + BaseEntity, + PrimaryGeneratedColumn, +} from "typeorm"; -export type CustomerStatus = "Active" | "Pending" | "Inactive"; -export type CustomerType = "Importer" | "Exporter" | "Supplier"; - -@Entity({schema:"freight", name: "customers" }) +@Entity("customers") export class Customer extends BaseEntity { - @Column({ name: "name", type: "varchar", length: 256 }) - name!: string; + @PrimaryGeneratedColumn("uuid") + id!: string; - @Column({ name: "email", type: "varchar", length: 256, unique: true }) + @Column({ type: "uuid" }) + @Index() + userId!: string; + + @Column({ length: 100 }) + @Index() + firstName!: string; + + @Column({ length: 100 }) + @Index() + lastName!: string; + + @Column({ unique: true, length: 150 }) + @Index() email!: string; - @Column({ name: "phone", type: "varchar", length: 32 }) + @Column({ length: 20 }) phone!: string; - @Column({ name: "company", type: "varchar", length: 256, nullable: true }) - company?: string | null; + @Column({ length: 200 }) + @Index() + companyName!: string; - @Column({ - name: "customer_type", - type: "varchar", - length: 32, - default: "Importer", - }) - customerType!: CustomerType; + @Column({ length: 150 }) + companyEmail!: string; - @Column({ - name: "status", - type: "varchar", - length: 32, - default: "Active", - }) - status!: CustomerStatus; + @Column({ length: 20 }) + companyPhone!: string; - @Column({ name: "tin_number", type: "varchar", length: 64, nullable: true }) - tinNumber?: string | null; + @Column({ length: 100 }) + companyLocation!: string; - @Column({ name: "city", type: "varchar", length: 128, nullable: true }) - city?: string | null; + @Column({ type: "text" }) + companyAddress!: string; - @Column({ name: "country", type: "varchar", length: 128, nullable: true }) - country?: string | null; + @Column({ length: 100 }) + contactPersonName!: string; - @Column({ name: "address", type: "text", nullable: true }) - address?: string | null; + @Column({ length: 20 }) + contactPersonPhone!: string; - @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) - taxId?: string | null; + @Column({ length: 10, unique: true }) + @Index() + tinNumber!: string; - @Column({ name: "notes", type: "text", nullable: true }) - notes?: string | null; -} + @Column({ length: 50, nullable: true }) + vatNumber?: string; + + @Column({ length: 16, unique: true }) + @Index() + fanNumber!: string; + + @Column({ length: 100 }) + generalManagerName!: string; + + @Column({ length: 150 }) + generalManagerEmail!: string; + + @Column({ length: 20 }) + generalManagerPhone!: string; + + @Column({ length: 100, nullable: true }) + poaName?: string; + + @Column({ length: 20, nullable: true }) + poaPhone?: string; + + @Column({ type: "text", nullable: true }) + poaAddress?: string; + + @Column({ nullable: true, length: 150 }) + poaEmail?: string; + + @Column({ length: 100, nullable: true }) + poaLocation?: string; + + @Column({ type: "text", nullable: true }) + notes?: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers2/customers.controller.ts b/apps/edr-freight-api/src/modules/customers2/customers.controller.ts new file mode 100644 index 000000000..85b470453 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.controller.ts @@ -0,0 +1,57 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { CustomersService } from "./customers.service"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; + +@ApiTags("customers") +@Controller("customers") +export class CustomersController { + constructor(private readonly customersService: CustomersService) {} + + @Post() + @ApiOperation({ summary: "Create a new customer" }) + create(@Body() dto: CreateCustomerDto) { + return this.customersService.create(dto); + } + + @Get() + @ApiOperation({ summary: "List all customers" }) + findAll() { + return this.customersService.findAll(); + } + + @Get(":id") + @ApiOperation({ summary: "Get a customer by ID" }) + findOne(@Param("id", ParseUUIDPipe) id: string) { + return this.customersService.findById(id); + } + + @Patch(":id") + @ApiOperation({ summary: "Update a customer" }) + update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateCustomerDto, + ) { + return this.customersService.update(id, dto); + } + + @Delete(":id") + @ApiOperation({ summary: "Soft-delete a customer" }) + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param("id", ParseUUIDPipe) id: string) { + return this.customersService.remove(id); + } +} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.module.ts b/apps/edr-freight-api/src/modules/customers2/customers.module.ts new file mode 100644 index 000000000..28c6b7c89 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { CustomersController } from "./customers.controller"; +import { CustomersRepository } from "./customers.repository"; +import { CustomersService } from "./customers.service"; +import { Customer } from "./entities/customer.entity"; + +@Module({ + imports: [TypeOrmModule.forFeature([Customer])], + controllers: [CustomersController], + providers: [CustomersService, CustomersRepository], + exports: [CustomersService], +}) +export class CustomersModule {} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.repository.ts b/apps/edr-freight-api/src/modules/customers2/customers.repository.ts new file mode 100644 index 000000000..c6cb72fcf --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.repository.ts @@ -0,0 +1,21 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Customer } from "./entities/customer.entity"; + +@Injectable() +export class CustomersRepository extends BaseRepository { + constructor( + @InjectRepository(Customer) + repository: Repository, + ) { + super(repository); + } + + /** Find a customer by their unique email. */ + findByEmail(email: string): Promise { + return this.repository.findOne({ where: { email } }); + } +} diff --git a/apps/edr-freight-api/src/modules/customers2/customers.service.ts b/apps/edr-freight-api/src/modules/customers2/customers.service.ts new file mode 100644 index 000000000..6394e1ad9 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/customers.service.ts @@ -0,0 +1,61 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; + +import { CustomersRepository } from "./customers.repository"; +import { CreateCustomerDto } from "./dto/create-customer.dto"; +import { UpdateCustomerDto } from "./dto/update-customer.dto"; +import { Customer } from "./entities/customer.entity"; + +@Injectable() +export class CustomersService { + constructor(private readonly customersRepository: CustomersRepository) {} + + async create(dto: CreateCustomerDto): Promise { + const existing = await this.customersRepository.findByEmail(dto.email); + if (existing) { + throw new ConflictException( + `Customer with email "${dto.email}" already exists`, + ); + } + return this.customersRepository.create(dto); + } + + findAll(): Promise { + return this.customersRepository.findAll({ order: { name: "ASC" } }); + } + + async findById(id: string): Promise { + const customer = await this.customersRepository.findById(id); + if (!customer) { + throw new NotFoundException(`Customer ${id} not found`); + } + return customer; + } + + async update(id: string, dto: UpdateCustomerDto): Promise { + await this.findById(id); + + if (dto.email) { + const conflict = await this.customersRepository.findByEmail(dto.email); + if (conflict && conflict.id !== id) { + throw new ConflictException( + `Customer with email "${dto.email}" already exists`, + ); + } + } + + const updated = await this.customersRepository.update(id, dto); + if (!updated) { + throw new NotFoundException(`Customer ${id} not found`); + } + return updated; + } + + async remove(id: string): Promise { + await this.findById(id); + await this.customersRepository.softDelete(id); + } +} diff --git a/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts new file mode 100644 index 000000000..854b3eaf1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/dto/create-customer.dto.ts @@ -0,0 +1,73 @@ +import { + IsEmail, + IsEnum, + IsOptional, + IsString, + MaxLength, +} from "class-validator"; + +export enum CustomerStatusDto { + Active = "Active", + Pending = "Pending", + Inactive = "Inactive", +} + +export enum CustomerTypeDto { + Importer = "Importer", + Exporter = "Exporter", + Supplier = "Supplier", +} + +export class CreateCustomerDto { + @IsString() + @MaxLength(256) + name!: string; + + @IsEmail() + email!: string; + + @IsString() + @MaxLength(32) + phone!: string; + + @IsOptional() + @IsString() + @MaxLength(256) + company?: string; + + @IsOptional() + @IsEnum(CustomerTypeDto) + customerType?: CustomerTypeDto; + + @IsOptional() + @IsEnum(CustomerStatusDto) + status?: CustomerStatusDto; + + @IsOptional() + @IsString() + @MaxLength(64) + tinNumber?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + city?: string; + + @IsOptional() + @IsString() + @MaxLength(128) + country?: string; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + taxId?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts new file mode 100644 index 000000000..94b49d0fd --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/dto/update-customer.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateCustomerDto } from "./create-customer.dto"; + +export class UpdateCustomerDto extends PartialType(CreateCustomerDto) {} diff --git a/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts b/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts new file mode 100644 index 000000000..98a248c97 --- /dev/null +++ b/apps/edr-freight-api/src/modules/customers2/entities/customer.entity.ts @@ -0,0 +1,54 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +export type CustomerStatus = "Active" | "Pending" | "Inactive"; +export type CustomerType = "Importer" | "Exporter" | "Supplier"; + +@Entity({schema:"freight", name: "customers" }) +export class Customer extends BaseEntity { + @Column({ name: "name", type: "varchar", length: 256 }) + name!: string; + + @Column({ name: "email", type: "varchar", length: 256, unique: true }) + email!: string; + + @Column({ name: "phone", type: "varchar", length: 32 }) + phone!: string; + + @Column({ name: "company", type: "varchar", length: 256, nullable: true }) + company?: string | null; + + @Column({ + name: "customer_type", + type: "varchar", + length: 32, + default: "Importer", + }) + customerType!: CustomerType; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: "Active", + }) + status!: CustomerStatus; + + @Column({ name: "tin_number", type: "varchar", length: 64, nullable: true }) + tinNumber?: string | null; + + @Column({ name: "city", type: "varchar", length: 128, nullable: true }) + city?: string | null; + + @Column({ name: "country", type: "varchar", length: 128, nullable: true }) + country?: string | null; + + @Column({ name: "address", type: "text", nullable: true }) + address?: string | null; + + @Column({ name: "tax_id", type: "varchar", length: 64, nullable: true }) + taxId?: string | null; + + @Column({ name: "notes", type: "text", nullable: true }) + notes?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts new file mode 100644 index 000000000..cac5fdba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpController } from './otp.controller'; + +describe('OtpController', () => { + let controller: OtpController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [OtpController], + }).compile(); + + controller = module.get(OtpController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/otp/otp.controller.ts b/apps/edr-freight-api/src/modules/otp/otp.controller.ts new file mode 100644 index 000000000..9866ca570 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.controller.ts @@ -0,0 +1,53 @@ +// otp.controller.ts + +import { + Body, + Controller, + Post, +} from "@nestjs/common"; + + +import { OtpService } from "./otp.service"; +import { Public } from "@edr/api-common"; + +@Controller("otp") +@Public() +export class OtpController { + constructor( + private readonly otpService: OtpService + ) {} + + // --------------------------------------------------------------------------- + // Send OTP + // --------------------------------------------------------------------------- + + @Post("send") + async sendOtp( + @Body("phone") + phone: string, + @Body("otp") + otp: string + ) { + return this.otpService.sendOtp( + phone,otp + ); + } + + // --------------------------------------------------------------------------- + // Verify OTP + // --------------------------------------------------------------------------- + + @Post("verify") + async verifyOtp( + @Body("phone") + phone: string, + + @Body("otp") + otp: string + ) { + return this.otpService.verifyOtp( + phone, + otp + ); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.entity.ts b/apps/edr-freight-api/src/modules/otp/otp.entity.ts new file mode 100644 index 000000000..f5900f6b8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.entity.ts @@ -0,0 +1,25 @@ +// otp.entity.ts + +import { + Column, + Entity, +} from "typeorm"; +import { BaseEntity } from "@edr/api-common"; + +@Entity({ + name: "otp_verifications", +}) +export class OtpVerification extends BaseEntity{ + @Column({ + unique: true, + }) + phone!: string; + + @Column() + otp!: string; + + @Column({ + default: false, + }) + verified!: boolean; +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.module.ts b/apps/edr-freight-api/src/modules/otp/otp.module.ts new file mode 100644 index 000000000..7a6d1faa6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.module.ts @@ -0,0 +1,33 @@ +// otp.module.ts + +import { Module } from "@nestjs/common"; + +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { OtpVerification } from "./otp.entity"; + +import { OtpController } from "./otp.controller"; + +import { OtpService } from "./otp.service"; + +import { OtpRepository } from "./otp.repository"; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + OtpVerification, + ]), + ], + + controllers: [OtpController], + + providers: [ + OtpService, + OtpRepository, + ], + + exports: [ + OtpRepository, + ], +}) +export class OtpModule {} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.repository.ts b/apps/edr-freight-api/src/modules/otp/otp.repository.ts new file mode 100644 index 000000000..8aa69dcd6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.repository.ts @@ -0,0 +1,86 @@ +// otp.repository.ts + +import { Injectable } from "@nestjs/common"; + +import { InjectRepository } from "@nestjs/typeorm"; + +import { Repository } from "typeorm"; + +import { OtpVerification } from "./otp.entity"; + +@Injectable() +export class OtpRepository { + constructor( + @InjectRepository( + OtpVerification + ) + private readonly repository: Repository + ) {} + + // --------------------------------------------------------------------------- + // Find By Phone + // --------------------------------------------------------------------------- + + async findByPhone( + phone: string + ) { + return this.repository.findOne({ + where: { + phone, + }, + }); + } + + // --------------------------------------------------------------------------- + // Create OTP + // --------------------------------------------------------------------------- + + async createOtp( + phone: string, + otp: string + ) { + const entity = + this.repository.create({ + phone, + otp, + verified: false, + }); + + return this.repository.save( + entity + ); + } + + // --------------------------------------------------------------------------- + // Update OTP + // --------------------------------------------------------------------------- + + async updateOtp( + otpVerification: OtpVerification, + otp: string + ) { + otpVerification.otp = otp; + + otpVerification.verified = + false; + + return this.repository.save( + otpVerification + ); + } + + // --------------------------------------------------------------------------- + // Verify Phone + // --------------------------------------------------------------------------- + + async verifyPhone( + otpVerification: OtpVerification + ) { + otpVerification.verified = + true; + + return this.repository.save( + otpVerification + ); + } +} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts new file mode 100644 index 000000000..28e2afc26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpService } from './otp.service'; + +describe('OtpService', () => { + let service: OtpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [OtpService], + }).compile(); + + service = module.get(OtpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/otp/otp.service.ts b/apps/edr-freight-api/src/modules/otp/otp.service.ts new file mode 100644 index 000000000..4e16be20a --- /dev/null +++ b/apps/edr-freight-api/src/modules/otp/otp.service.ts @@ -0,0 +1,141 @@ +// otp.service.ts + +import { + BadRequestException, + Injectable, +} from "@nestjs/common"; + +import axios from "axios"; + +import { OtpRepository } from "./otp.repository"; + +@Injectable() +export class OtpService { + constructor( + private readonly otpRepository: OtpRepository + ) {} + + // --------------------------------------------------------------------------- + // Generate OTP + // --------------------------------------------------------------------------- + + generateOtp(): string { + return Math.floor( + 100000 + Math.random() * 900000 + ).toString(); + } + + // --------------------------------------------------------------------------- + // Send OTP + // --------------------------------------------------------------------------- + + async sendOtp(phone: string, otp: string) { + try { + // generate otp + // const otp = + // this.generateOtp(); + + // find existing phone + const existingPhone = + await this.otpRepository.findByPhone( + phone + ); + + // update existing otp + if (existingPhone) { + await this.otpRepository.updateOtp( + existingPhone, + otp + ); + } else { + // create new otp + await this.otpRepository.createOtp( + phone, + otp + ); + } + + // send sms + await axios.post( + "https://notification-dev.license.aafda.gov.et/api/sms-services/ozeking/sms", + { + to: phone, + + sourceId: "EDR", + + sourceName: + "EDR Freight", + + appKey: + "YOUR_APP_KEY", + + text: `Your verification code is ${otp}`, + + callbackUrl: "", + }, + { + headers: { + accept: "*/*", + + "Content-Type": + "application/json", + }, + } + ); + + return { + success: true, + + message: + "OTP sent successfully", + }; + } catch (error) { + console.log(error); + + throw new BadRequestException( + "Failed to send OTP" + ); + } + } + + // --------------------------------------------------------------------------- + // Verify OTP + // --------------------------------------------------------------------------- + + async verifyOtp( + phone: string, + otp: string + ) { + // find phone + const otpData = + await this.otpRepository.findByPhone( + phone + ); + + // phone not found + if (!otpData) { + throw new BadRequestException( + "Phone number not found" + ); + } + + // invalid otp + if (otpData.otp !== otp) { + throw new BadRequestException( + "Invalid OTP" + ); + } + + // verify phone + await this.otpRepository.verifyPhone( + otpData + ); + + return { + success: true, + + message: + "Phone verified successfully", + }; + } +} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 111df497b..968f0925d 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -44,11 +44,15 @@ import DocumentsPage from "./pages/documents/DocumentsPage"; import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage"; import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage"; import MyPortalPage from "./pages/portal/MyPortalPage"; +import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; +import SignupPage from "./pages/accounts/SignupPage"; +import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; +import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import Station from "./components/stations/Station"; const sidebarItems: SidebarItem[] = [ - { label: "My Portal", href: "/portal", icon: }, - { label: "Dashboard", href: "/", icon: }, + { label: "My Portal", href: "/", icon: }, + { label: "Dashboard", href: "/dashboard", icon: }, { label: "Customers", href: "/customers", icon: }, { label: "My Bookings", href: "/bookings", icon: }, { label: "Consignments", href: "/consignments", icon: }, @@ -75,11 +79,15 @@ const App = () => { return ; } - if (!user) { + if (user) { return ( + } /> + } /> + } /> + } /> } /> - } /> + {/* } /> */} ); } @@ -114,8 +122,8 @@ const App = () => { onLogout={handleLogout} > - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts b/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts index 20a6c08ee..a07e2a2a5 100644 --- a/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts +++ b/apps/edr-freight-web/portal/src/constants/FILE_SETTINGS.ts @@ -1,3 +1,4 @@ export const FILE_SETTINGS = { - CUSTOMER_REGISTRATION: "customer_registration" + CUSTOMER_REGISTRATION: "customer_registration", + } \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts b/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts index 6ca9c2361..42bfa33ff 100644 --- a/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts +++ b/apps/edr-freight-web/portal/src/constants/TANSTACK_QUEY_KEY.ts @@ -1,5 +1,6 @@ export const QUERY_KEYS = { USERS: "users", + ADD_USER: "add_user", CUSTOMER: "Customers", FILES: { FILE_UPLOAD_SETTINGS: "file-upload-settings", diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index c4ad9512d..f22b31e39 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -8,8 +8,12 @@ export const URL_CONSTANTS = { }, USERS: { + SIGN_UP: "/api/auth/signup", + GENERATE_VERIFICATION_CODE: "/api/auth/generate-verification-code", BASE: "/users", BY_ID: (id: string | number) => `/users/${id}`, + SET_PASSWORD: "/api/auth/set-password", + ME: "/api/auth/me" }, ROLES: { @@ -64,10 +68,11 @@ export const URL_CONSTANTS = { BY_ID: (id: string | number) => `/customers/${id}`, BOOKINGS: (id: string | number) => `/customers/${id}/bookings`, }, - + CUSTOMERS_API: { BASE: "/api/customers", BY_ID: (id: string) => `/api/customers/${id}`, + BY_USER_ID: (id: string) => `/api/customers/user/${id}` }, BOOKINGS: { @@ -76,4 +81,9 @@ export const URL_CONSTANTS = { CANCEL: (id: string | number) => `/bookings/${id}/cancel`, CONFIRM: (id: string | number) => `/bookings/${id}/confirm`, }, + + OTP: { + SEND: "/api/otp/send", + VERIFY: "/api/otp/verify", + } }; \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/enums/userType.ts b/apps/edr-freight-web/portal/src/enums/userType.ts new file mode 100644 index 000000000..b0e8dba8a --- /dev/null +++ b/apps/edr-freight-web/portal/src/enums/userType.ts @@ -0,0 +1,5 @@ +export enum userType { + externalOrganization = "external_organization", + employee = "employee", + individual = "individual" +} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/enums/verificationCodeType.ts b/apps/edr-freight-web/portal/src/enums/verificationCodeType.ts new file mode 100644 index 000000000..2c3db2277 --- /dev/null +++ b/apps/edr-freight-web/portal/src/enums/verificationCodeType.ts @@ -0,0 +1,6 @@ +export enum verificationCodeType { + setPassword = "set-password", + resetPassword = "reset-password", + verifyPhoneNumber = "verify-phone-number", + mfaLogin = "mfa-login", +} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx new file mode 100644 index 000000000..d0faeb662 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/EDRFreightLandingPage.tsx @@ -0,0 +1,612 @@ +import { + ArrowRight, + BarChart3, + CheckCircle2, + Clock3, + Globe2, + Mail, + MapPin, + Menu, + Phone, + ShieldCheck, + Train, + Truck, + Users, +} from "lucide-react"; + +const stats = [ + { + label: "Active Corridors", + value: "24+", + icon: Globe2, + }, + { + label: "Monthly Shipments", + value: "12K+", + icon: Truck, + }, + { + label: "Fleet Coverage", + value: "16 Trains", + icon: Train, + }, + { + label: "On-time Delivery", + value: "100%", + icon: Clock3, + }, +]; + +const features = [ + { + title: "Real-time Shipment Tracking", + description: + "Track consignments across Addis Ababa, Dire Dawa, Djibouti, Mojo, and all major freight corridors.", + icon: Truck, + }, + { + 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, + }, +]; + +const corridors = [ + "Addis Ababa → Djibouti", + "Dire Dawa → Djibouti", + "Adama → Dire Dawa", + "Mojo → Djibouti", + "Awash → Holhol", + "Mieso → Aysha", +]; + +export default function EDRFreightLandingPage() { + return ( +
+ {/* Navbar */} +
+
+
+
+ +
+ +
+

+ EDR Freight +

+ +

+ Rail Logistics Platform +

+
+
+ + + + +
+
+ + {/* Hero */} +
+
+ +
+
+
+ + Ethiopia–Djibouti Railway Freight Platform +
+ +

+ Smarter Railway Freight Logistics For Modern Operations +

+ +

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

+ + + +
+ {[ + "Real-time Tracking", + "Railway Analytics", + "Secure Operations", + "Multi-corridor Freight", + ].map((item) => ( +
+ + {item} +
+ ))} +
+
+ + {/* Hero Dashboard Card */} +
+
+
+
+

+ Freight Operations +

+ +

+ Live Statistics +

+
+ +
+ +
+
+ +
+ {stats.map((item) => { + const Icon = item.icon; + + return ( +
+
+ +
+ +

+ {item.value} +

+ +

+ {item.label} +

+
+ ); + })} +
+ +
+
+
+

+ Corridor Performance +

+ +

+ 99% +

+
+ +
+ Operational +
+
+ +
+
+
+
+
+ +
+
+
+ +
+ +
+

+ 16 Trains Active +

+ +

+ Across all freight corridors +

+
+
+
+
+
+
+ + {/* Features */} +
+
+
+
+ Platform Features +
+ +

+ Everything needed for freight operations +

+ +

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

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

+ {feature.title} +

+ +

+ {feature.description} +

+
+ ); + })} +
+
+
+ + {/* Corridors */} +
+
+
+
+
+ Freight Corridors +
+ +

+ Connected logistics infrastructure +

+ +

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

+ +
+ {corridors.map((corridor) => ( +
+
+ + {corridor} +
+ ))} +
+
+ +
+
+
+

+ Operational Insights +

+ +

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

+
+
+
+
+
+
+
+ + {/* Contact */} +
+
+
+
+
+ Contact Us +
+ +

+ Let’s move freight smarter +

+ +

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

+ +
+
+
+ +
+ +
+

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

+ +
+ + + + +