From 81a495ec61214a953860cc183b635dfc45c67fec Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 18 Jun 2026 14:41:16 +0300 Subject: [PATCH 1/9] refactor: cut off the old customre entity --- apps/edr-freight-api/src/app.module.ts | 18 +- .../modules/customers/customers.controller.ts | 86 ---- .../src/modules/customers/customers.module.ts | 15 - .../modules/customers/customers.repository.ts | 117 ------ .../modules/customers/customers.service.ts | 140 ------- .../customers/dto/create-customer.dto.ts | 156 -------- .../customers/dto/response-customer.dto.ts | 60 --- .../customers/dto/update-customer.dto.ts | 9 - .../customers/entities/customer.entity.ts | 87 ---- .../src/modules/overview/overview.module.ts | 32 +- .../modules/overview/overview.repository.ts | 376 ++++++++++-------- 11 files changed, 233 insertions(+), 863 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/customers/customers.controller.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/customers.module.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/customers.repository.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/customers.service.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index ec1664f15..7bd38c935 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -24,14 +24,13 @@ import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSchedulesModule } from "./modules/train-schedules/train-schedules.module"; import { TrainSchedulingModule } from "./modules/train-scheduling/train-scheduling.module"; import { SchedulingRescheduleModule } from "./modules/scheduling-reschedule/scheduling-reschedule.module"; -import { CustomersModule } from "./modules/customers/customers.module"; import { CompaniesModule } from "./modules/companies/companies.module"; import { TrackingModule } from "./modules/tracking/tracking.module"; 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 { OtpModule } from "./modules/otp/otp.module"; import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module"; import { BackofficeModule } from "./modules/backoffice/backoffice.module"; import { DemoPermissionsModule } from "./modules/demo-permissions/demo-permissions.module"; @@ -53,13 +52,13 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; -import { WagonsModule } from './modules/wagons/wagons.module'; -import { ContainersModule } from './modules/container-management/containers.module'; -import { CargoesModule } from './modules/cargoes/cargoes.module'; -import { RoutesModule } from './modules/routes/routes.module'; -import { WarehousesModule } from './modules/warehouses/warehouses.module'; -import { FacilitiesModule } from './modules/facilities/facilities.module'; -import { OverviewModule } from './modules/overview/overview.module'; +import { WagonsModule } from "./modules/wagons/wagons.module"; +import { ContainersModule } from "./modules/container-management/containers.module"; +import { CargoesModule } from "./modules/cargoes/cargoes.module"; +import { RoutesModule } from "./modules/routes/routes.module"; +import { WarehousesModule } from "./modules/warehouses/warehouses.module"; +import { FacilitiesModule } from "./modules/facilities/facilities.module"; +import { OverviewModule } from "./modules/overview/overview.module"; @Module({ imports: [ @@ -97,7 +96,6 @@ import { OverviewModule } from './modules/overview/overview.module'; TrainSchedulesModule, TrainSchedulingModule, SchedulingRescheduleModule, - CustomersModule, CompaniesModule, TrackingModule, BillingModule, diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts deleted file mode 100644 index 7451bd6b6..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ /dev/null @@ -1,86 +0,0 @@ -// src/modules/customers/customers.controller.ts - -import { - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Patch, - Post, - Body, - Query, -} from "@nestjs/common"; - -import { ApiOperation } from "@nestjs/swagger"; - -import { FreightAdmin } from "../../common/booking-guards"; -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"; - -@Controller("customers") -@FreightAdmin() -export class CustomersController { - constructor(private readonly customersService: CustomersService) {} - - @Post() - create(@Body() createCustomerDto: CreateCustomerDto): Promise { - return this.customersService.create(createCustomerDto); - } - - @Get() - 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") - 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): Promise { - return this.customersService.delete(id); - } -} \ No newline at end of file diff --git a/apps/edr-freight-api/src/modules/customers/customers.module.ts b/apps/edr-freight-api/src/modules/customers/customers.module.ts deleted file mode 100644 index 28c6b7c89..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.module.ts +++ /dev/null @@ -1,15 +0,0 @@ -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/customers/customers.repository.ts b/apps/edr-freight-api/src/modules/customers/customers.repository.ts deleted file mode 100644 index 5933cef61..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.repository.ts +++ /dev/null @@ -1,117 +0,0 @@ -// 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, 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 { - constructor( - @InjectRepository(Customer) - private readonly repository: Repository, - ) { } - - async create(dto: CreateCustomerDto): Promise { - const customer = this.repository.create(dto); - return await this.repository.save(customer); - } - - 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.companyName 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 deleted file mode 100644 index e3d1f3a82..000000000 --- a/apps/edr-freight-api/src/modules/customers/customers.service.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { - Injectable, - NotFoundException, - ConflictException, - BadRequestException, -} 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) {} - - /** Create a new customer */ - async create(dto: CreateCustomerDto): Promise { - const exists = await this.customersRepository.existsByUniqueFields( - dto.email, - dto.vatNumber, - ); - - if (exists) { - throw new ConflictException( - "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: { companyName: "ASC" }, - }); - } - - /** Get customer by ID */ - async findById(id: string): Promise { - const customer = await this.customersRepository.findById(id); - - if (!customer) { - 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 existing = await this.customersRepository.findByEmail(dto.email); - - // // if (existing && existing.userId !== 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; - } - - /** 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 deleted file mode 100644 index 39fc16414..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/create-customer.dto.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - IsEmail, - IsEnum, - IsOptional, - IsString, - MaxLength, - IsNotEmpty, - Length, - Matches, -} from "class-validator"; - -// Enums -export enum CustomerStatusDto { - Active = "Active", - Pending = "Pending", - Inactive = "Inactive", -} - -export enum CustomerTypeDto { - Importer = "Importer", - Exporter = "Exporter", - Supplier = "Supplier", -} - -// DTO -export class CreateCustomerDto { - // Basic identity - @IsString() - @IsNotEmpty() - userId!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - firstName!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - lastName!: string; - - @IsEmail() - @IsNotEmpty() - email!: string; - - @IsString() - @IsNotEmpty() - @MaxLength(20) - phone!: string; - - // Company info - @IsString() - @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; - - @IsOptional() - @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(100) - poaName?: string; - - @IsOptional() - @IsString() - @MaxLength(20) - poaPhone?: string; - - @IsOptional() - @IsString() - poaAddress?: string; - - @IsOptional() - @IsEmail() - poaEmail?: string; - - @IsOptional() - @IsString() - @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 deleted file mode 100644 index 3d2a086d4..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/response-customer.dto.ts +++ /dev/null @@ -1,60 +0,0 @@ -// 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 ?? undefined; - 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 deleted file mode 100644 index f8cefe046..000000000 --- a/apps/edr-freight-api/src/modules/customers/dto/update-customer.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -// src/modules/customers/dto/update-customer.dto.ts -import { PartialType } from '@nestjs/mapped-types'; -import { CreateCustomerDto } from './create-customer.dto'; - -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 deleted file mode 100644 index abccbaef8..000000000 --- a/apps/edr-freight-api/src/modules/customers/entities/customer.entity.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index } from 'typeorm'; - -@Entity({ schema: 'freight', name: 'customers' }) -@Index(['email']) -//@Index(['userId']) -@Index(['tinNumber']) -@Index(['fanNumber']) -export class Customer extends BaseEntity { - //@Column({ name: 'user_id', type: 'uuid' }) - //userId!: string; - - @Column({ name: 'first_name', type: 'varchar', length: 100 }) - firstName!: string; - - @Column({ name: 'last_name', type: 'varchar', length: 100 }) - lastName!: string; - - @Column({ name: 'email', type: 'varchar', length: 150, unique: true }) - email!: string; - - @Column({ name: 'phone', type: 'varchar', length: 20 }) - phone!: string; - - @Column({ name: 'company_name', type: 'varchar', length: 200 }) - companyName!: string; - - @Column({ name: 'company_email', type: 'varchar', length: 150 }) - companyEmail!: string; - - @Column({ name: 'company_phone', type: 'varchar', length: 20 }) - companyPhone!: string; - - @Column({ name: 'company_location', type: 'varchar', length: 100 }) - companyLocation!: string; - - @Column({ name: 'company_address', type: 'text' }) - companyAddress!: string; - - @Column({ name: 'customer_type', type: 'varchar', length: 32, nullable: true }) - customerType?: string | null; - - @Column({ name: 'status', type: 'varchar', length: 32, nullable: true }) - status?: string | null; - - @Column({ name: 'contact_person_name', type: 'varchar', length: 100 }) - contactPersonName!: string; - - @Column({ name: 'contact_person_phone', type: 'varchar', length: 20 }) - contactPersonPhone!: string; - - @Column({ name: 'tin_number', type: 'varchar', length: 10, unique: true }) - tinNumber!: string; - - @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) - vatNumber?: string | null; - - @Column({ name: 'fan_number', type: 'varchar', length: 16, unique: true }) - fanNumber!: string; - - @Column({ name: 'general_manager_name', type: 'varchar', length: 100 }) - generalManagerName!: string; - - @Column({ name: 'general_manager_email', type: 'varchar', length: 150 }) - generalManagerEmail!: string; - - @Column({ name: 'general_manager_phone', type: 'varchar', length: 20 }) - generalManagerPhone!: string; - - @Column({ name: 'poa_name', type: 'varchar', length: 100, nullable: true }) - poaName?: string | null; - - @Column({ name: 'poa_phone', type: 'varchar', length: 20, nullable: true }) - poaPhone?: string | null; - - @Column({ name: 'poa_address', type: 'text', nullable: true }) - poaAddress?: string | null; - - @Column({ name: 'poa_email', type: 'varchar', length: 150, nullable: true }) - poaEmail?: string | null; - - @Column({ name: 'poa_location', type: 'varchar', length: 100, nullable: true }) - poaLocation?: string | null; - - @Column({ name: 'notes', type: 'text', nullable: true }) - notes?: string | null; -} diff --git a/apps/edr-freight-api/src/modules/overview/overview.module.ts b/apps/edr-freight-api/src/modules/overview/overview.module.ts index 50893b626..87cdc62d5 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.module.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.module.ts @@ -1,25 +1,25 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { Employee } from '@tria-plc/iamapi-common'; -import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Cargo } from '../cargoes/entities/cargoes.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Customer } from '../customers/entities/customer.entity'; -import { PaymentEntity } from '../payment/entities/payment.entity'; -import { Train } from '../trains/entities/train.entity'; -import { Wagon } from '../wagons/entities/wagon.entity'; -import { OverviewController } from './overview.controller'; -import { OverviewRepository } from './overview.repository'; -import { OverviewService } from './overview.service'; +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { Company } from "../companies/entities/company.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; +import { OverviewController } from "./overview.controller"; +import { OverviewRepository } from "./overview.repository"; +import { OverviewService } from "./overview.service"; @Module({ imports: [ TypeOrmModule.forFeature([ Booking, PaymentEntity, - Customer, + Company, Train, Wagon, Container, @@ -31,4 +31,4 @@ import { OverviewService } from './overview.service'; controllers: [OverviewController], providers: [OverviewService, OverviewRepository], }) -export class OverviewModule {} +export class OverviewModule { } diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 2c49ff8da..a57ba24db 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -1,24 +1,24 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; -import { Employee } from '@tria-plc/iamapi-common'; -import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity'; -import { Freight } from '@edr/types'; -import { Repository, ObjectLiteral } from 'typeorm'; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { EUserStatus } from "@tria-plc/api-common/utils/enums/user.enum"; +import { Employee } from "@tria-plc/iamapi-common"; +import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; +import { Freight } from "@edr/types"; +import { Repository, ObjectLiteral } from "typeorm"; -import { Booking } from '../bookings/entities/booking.entity'; -import { Cargo } from '../cargoes/entities/cargoes.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Customer } from '../customers/entities/customer.entity'; -import { PaymentEntity } from '../payment/entities/payment.entity'; -import { Train } from '../trains/entities/train.entity'; -import { Wagon } from '../wagons/entities/wagon.entity'; +import { Booking } from "../bookings/entities/booking.entity"; +import { Cargo } from "../cargoes/entities/cargoes.entity"; +import { Container } from "../container-management/entities/container.entity"; +import { PaymentEntity } from "../payment/entities/payment.entity"; +import { Train } from "../trains/entities/train.entity"; +import { Wagon } from "../wagons/entities/wagon.entity"; import { OVERVIEW_CLOSED_STATUSES, OVERVIEW_IN_APPROVAL_STATUSES, OVERVIEW_NEEDS_ACTION_STATUSES, OVERVIEW_URGENT_PRIORITY_THRESHOLD, -} from './overview.constants'; +} from "./overview.constants"; +import { Company } from "../companies/entities/company.entity"; export type OverviewBookingKpisRow = { totalActive: number; @@ -46,8 +46,8 @@ export class OverviewRepository { private readonly bookingRepository: Repository, @InjectRepository(PaymentEntity) private readonly paymentRepository: Repository, - @InjectRepository(Customer) - private readonly customerRepository: Repository, + @InjectRepository(Company) + private readonly companyRepository: Repository, @InjectRepository(Train) private readonly trainRepository: Repository, @InjectRepository(Wagon) @@ -60,32 +60,32 @@ export class OverviewRepository { private readonly employeeRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, - ) {} + ) { } async getBookingKpis(): Promise { const row = await this.bookingRepository - .createQueryBuilder('booking') + .createQueryBuilder("booking") .select( `COUNT(*) FILTER (WHERE booking.status NOT IN (:...closedStatuses) AND booking.status != 'DRAFT')::int`, - 'totalActive', + "totalActive", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...needsActionStatuses))::int`, - 'needsAction', + "needsAction", ) .addSelect( `COUNT(*) FILTER (WHERE booking.priority_score >= :urgentThreshold)::int`, - 'urgent', + "urgent", ) .addSelect( `COUNT(*) FILTER (WHERE booking.status IN (:...inApprovalStatuses))::int`, - 'inApproval', + "inApproval", ) .addSelect( `COUNT(*) FILTER (WHERE booking.created_at >= CURRENT_DATE AND booking.status != 'DRAFT')::int`, - 'submittedToday', + "submittedToday", ) - .where('booking.deleted_at IS NULL') + .where("booking.deleted_at IS NULL") .setParameters({ closedStatuses: [...OVERVIEW_CLOSED_STATUSES], needsActionStatuses: [...OVERVIEW_NEEDS_ACTION_STATUSES], @@ -112,9 +112,9 @@ export class OverviewRepository { const [trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded] = await Promise.all([ this.trainRepository - .createQueryBuilder('train') - .where('train.deleted_at IS NULL') - .andWhere('train.status IN (:...statuses)', { + .createQueryBuilder("train") + .where("train.deleted_at IS NULL") + .andWhere("train.status IN (:...statuses)", { statuses: [ Freight.TrainStatus.InService, Freight.TrainStatus.Scheduled, @@ -122,39 +122,46 @@ export class OverviewRepository { }) .getCount(), this.wagonRepository - .createQueryBuilder('wagon') - .where('wagon.deleted_at IS NULL') - .andWhere('wagon.status = :status', { status: Freight.WagonStatus.Available }) + .createQueryBuilder("wagon") + .where("wagon.deleted_at IS NULL") + .andWhere("wagon.status = :status", { + status: Freight.WagonStatus.Available, + }) .getCount(), this.containerRepository - .createQueryBuilder('container') - .where('container.deleted_at IS NULL') - .andWhere('container.status = :status', { status: 'IN_TRANSIT' }) + .createQueryBuilder("container") + .where("container.deleted_at IS NULL") + .andWhere("container.status = :status", { status: "IN_TRANSIT" }) .getCount(), this.cargoRepository - .createQueryBuilder('cargo') - .where('cargo.deleted_at IS NULL') - .andWhere('cargo.status IN (:...statuses)', { - statuses: ['LOADED', 'IN_TRANSIT'], + .createQueryBuilder("cargo") + .where("cargo.deleted_at IS NULL") + .andWhere("cargo.status IN (:...statuses)", { + statuses: ["LOADED", "IN_TRANSIT"], }) .getCount(), ]); - return { trainsActive, wagonsAvailable, containersInTransit, cargoesLoaded }; + return { + trainsActive, + wagonsAvailable, + containersInTransit, + cargoesLoaded, + }; } async getCustomerKpis(): Promise<{ totalCustomers: number; newCustomersThisMonth: number; }> { - const row = await this.customerRepository - .createQueryBuilder('customer') - .select('COUNT(*)::int', 'totalCustomers') + const row = await this.companyRepository + .createQueryBuilder("customer") + .select("COUNT(*)::int", "totalCustomers") .addSelect( `COUNT(*) FILTER (WHERE customer.created_at >= date_trunc('month', CURRENT_DATE))::int`, - 'newCustomersThisMonth', + "newCustomersThisMonth", ) - .where('customer.deleted_at IS NULL') + .where("customer.deleted_at IS NULL") .getRawOne>(); return { @@ -170,26 +177,26 @@ export class OverviewRepository { successfulPaymentsMtd: number; }> { const revenueRow = await this.paymentRepository - .createQueryBuilder('payment') + .createQueryBuilder("payment") .select( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, - 'revenueMtdEtb', + "revenueMtdEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, - 'revenueMtdUsd', + "revenueMtdUsd", ) - .addSelect(`COUNT(*)::int`, 'successfulPaymentsMtd') - .where('payment.status = :status', { status: 'success' }) + .addSelect(`COUNT(*)::int`, "successfulPaymentsMtd") + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) .getRawOne>(); const pendingPayments = await this.paymentRepository - .createQueryBuilder('payment') - .where('payment.status IN (:...statuses)', { - statuses: ['action-required', 'processing'], + .createQueryBuilder("payment") + .where("payment.status IN (:...statuses)", { + statuses: ["action-required", "processing"], }) .getCount(); @@ -201,7 +208,10 @@ export class OverviewRepository { }; } - async getStaffKpis(): Promise<{ activeEmployees: number; activeUsers: number }> { + async getStaffKpis(): Promise<{ + activeEmployees: number; + activeUsers: number; + }> { const [activeEmployees, activeUsers] = await Promise.all([ this.employeeRepository.count({ where: { isCurrent: true }, @@ -217,15 +227,17 @@ export class OverviewRepository { return { activeEmployees, activeUsers }; } - async getBookingTrend(days: number): Promise<{ date: string; count: number }[]> { + async getBookingTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('booking.created_at::date') - .orderBy('booking.created_at::date', 'ASC') + .groupBy("booking.created_at::date") + .orderBy("booking.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -236,11 +248,11 @@ export class OverviewRepository { async getStatusCounts(): Promise> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') - .groupBy('booking.status') + .createQueryBuilder("booking") + .select("booking.status", "status") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .groupBy("booking.status") .getRawMany<{ status: string; count: string }>(); return Object.fromEntries( @@ -252,26 +264,26 @@ export class OverviewRepository { days: number, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const rows = await this.paymentRepository - .createQueryBuilder('payment') + .createQueryBuilder("payment") .select( `to_char(COALESCE(payment.paid_at, payment.created_at)::date, 'YYYY-MM-DD')`, - 'date', + "date", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, - 'amountEtb', + "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, - 'amountUsd', + "amountUsd", ) - .where('payment.status = :status', { status: 'success' }) + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, { days }, ) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) - .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, 'ASC') + .orderBy(`COALESCE(payment.paid_at, payment.created_at)::date`, "ASC") .getRawMany<{ date: string; amountEtb: string; amountUsd: string }>(); return rows.map((row) => ({ @@ -283,18 +295,18 @@ export class OverviewRepository { async getRecentBookings(limit: number): Promise { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .leftJoin('booking.company', 'company') - .select('booking.id', 'id') - .addSelect('booking.reference', 'reference') - .addSelect('COALESCE(company.name, \'—\')', 'customerLabel') - .addSelect('booking.status', 'status') - .addSelect('booking.priority_score', 'priorityScore') - .addSelect('booking.total_amount', 'totalAmount') - .addSelect('booking.payment_currency', 'paymentCurrency') - .addSelect('booking.created_at', 'createdAt') - .where('booking.deleted_at IS NULL') - .orderBy('booking.created_at', 'DESC') + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select("booking.id", "id") + .addSelect("booking.reference", "reference") + .addSelect("COALESCE(company.name, '—')", "customerLabel") + .addSelect("booking.status", "status") + .addSelect("booking.priority_score", "priorityScore") + .addSelect("booking.total_amount", "totalAmount") + .addSelect("booking.payment_currency", "paymentCurrency") + .addSelect("booking.created_at", "createdAt") + .where("booking.deleted_at IS NULL") + .orderBy("booking.created_at", "DESC") .limit(limit) .getRawMany<{ id: string; @@ -319,15 +331,17 @@ export class OverviewRepository { })); } - async getBookingsByFreightType(): Promise<{ label: string; count: number }[]> { + async getBookingsByFreightType(): Promise< + { label: string; count: number }[] + > { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.freight_type', 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select("booking.freight_type", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('booking.freight_type') - .orderBy('count', 'DESC') + .groupBy("booking.freight_type") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -338,13 +352,13 @@ export class OverviewRepository { async getBookingsByCurrency(): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .select('booking.payment_currency', 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .select("booking.payment_currency", "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('booking.payment_currency') - .orderBy('count', 'DESC') + .groupBy("booking.payment_currency") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -355,11 +369,11 @@ export class OverviewRepository { async getPaymentsByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .groupBy('payment.status') - .orderBy('count', 'DESC') + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -372,20 +386,25 @@ export class OverviewRepository { { method: string; count: number; amountEtb: number; amountUsd: number }[] > { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.method', 'method') - .addSelect('COUNT(*)::int', 'count') + .createQueryBuilder("payment") + .select("payment.method", "method") + .addSelect("COUNT(*)::int", "count") .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB' AND payment.status = 'success'), 0)`, - 'amountEtb', + "amountEtb", ) .addSelect( `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD' AND payment.status = 'success'), 0)`, - 'amountUsd', + "amountUsd", ) - .groupBy('payment.method') - .orderBy('count', 'DESC') - .getRawMany<{ method: string; count: string; amountEtb: string; amountUsd: string }>(); + .groupBy("payment.method") + .orderBy("count", "DESC") + .getRawMany<{ + method: string; + count: string; + amountEtb: string; + amountUsd: string; + }>(); return rows.map((row) => ({ method: row.method, @@ -395,16 +414,18 @@ export class OverviewRepository { })); } - async getRevenueByCurrency(): Promise<{ currency: string; amount: number }[]> { + async getRevenueByCurrency(): Promise< + { currency: string; amount: number }[] + > { const rows = await this.paymentRepository - .createQueryBuilder('payment') - .select('payment.currency', 'currency') - .addSelect('COALESCE(SUM(payment.amount), 0)', 'amount') - .where('payment.status = :status', { status: 'success' }) + .createQueryBuilder("payment") + .select("payment.currency", "currency") + .addSelect("COALESCE(SUM(payment.amount), 0)", "amount") + .where("payment.status = :status", { status: "success" }) .andWhere( `COALESCE(payment.paid_at, payment.created_at) >= date_trunc('month', CURRENT_DATE)`, ) - .groupBy('payment.currency') + .groupBy("payment.currency") .getRawMany<{ currency: string; amount: string }>(); return rows.map((row) => ({ @@ -413,20 +434,28 @@ export class OverviewRepository { })); } - async getTrainStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.trainRepository, 'train'); + async getTrainStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.trainRepository, "train"); } - async getWagonStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.wagonRepository, 'wagon'); + async getWagonStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.wagonRepository, "wagon"); } - async getContainerStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.containerRepository, 'container'); + async getContainerStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.containerRepository, "container"); } - async getCargoStatusBreakdown(): Promise<{ status: string; count: number }[]> { - return this.statusBreakdown(this.cargoRepository, 'cargo'); + async getCargoStatusBreakdown(): Promise< + { status: string; count: number }[] + > { + return this.statusBreakdown(this.cargoRepository, "cargo"); } private async statusBreakdown( @@ -435,11 +464,11 @@ export class OverviewRepository { ): Promise<{ status: string; count: number }[]> { const rows = await repository .createQueryBuilder(alias) - .select(`${alias}.status`, 'status') - .addSelect('COUNT(*)::int', 'count') + .select(`${alias}.status`, "status") + .addSelect("COUNT(*)::int", "count") .where(`${alias}.deleted_at IS NULL`) .groupBy(`${alias}.status`) - .orderBy('count', 'DESC') + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -448,15 +477,19 @@ export class OverviewRepository { })); } - async getCustomerGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { - const rows = await this.customerRepository - .createQueryBuilder('customer') - .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('customer.deleted_at IS NULL') - .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('customer.created_at::date') - .orderBy('customer.created_at::date', 'ASC') + async getCustomerGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select(`to_char(customer.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .andWhere(`customer.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("customer.created_at::date") + .orderBy("customer.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -466,13 +499,16 @@ export class OverviewRepository { } async getCustomersByType(): Promise<{ label: string; count: number }[]> { - const rows = await this.customerRepository - .createQueryBuilder('customer') - .select(`COALESCE(NULLIF(customer.customer_type, ''), 'Unknown')`, 'label') - .addSelect('COUNT(*)::int', 'count') - .where('customer.deleted_at IS NULL') - .groupBy('customer.customer_type') - .orderBy('count', 'DESC') + const rows = await this.companyRepository + .createQueryBuilder("customer") + .select( + `COALESCE(NULLIF(customer.type, ''), 'Unknown')`, + "label", + ) + .addSelect("COUNT(*)::int", "count") + .where("customer.deleted_at IS NULL") + .groupBy("customer.type") + .orderBy("count", "DESC") .getRawMany<{ label: string; count: string }>(); return rows.map((row) => ({ @@ -481,16 +517,18 @@ export class OverviewRepository { })); } - async getTopCustomersByBookings(limit: number): Promise<{ label: string; count: number }[]> { + async getTopCustomersByBookings( + limit: number, + ): Promise<{ label: string; count: number }[]> { const rows = await this.bookingRepository - .createQueryBuilder('booking') - .leftJoin('booking.company', 'company') - .select(`COALESCE(company.name, 'Unknown')`, 'label') - .addSelect('COUNT(*)::int', 'count') - .where('booking.deleted_at IS NULL') + .createQueryBuilder("booking") + .leftJoin("booking.company", "company") + .select(`COALESCE(company.name, 'Unknown')`, "label") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") .andWhere("booking.status != 'DRAFT'") - .groupBy('company.name') - .orderBy('count', 'DESC') + .groupBy("company.name") + .orderBy("count", "DESC") .limit(limit) .getRawMany<{ label: string; count: string }>(); @@ -502,11 +540,11 @@ export class OverviewRepository { async getUsersByStatus(): Promise<{ status: string; count: number }[]> { const rows = await this.userRepository - .createQueryBuilder('user') - .select('user.status', 'status') - .addSelect('COUNT(*)::int', 'count') - .groupBy('user.status') - .orderBy('count', 'DESC') + .createQueryBuilder("user") + .select("user.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("user.status") + .orderBy("count", "DESC") .getRawMany<{ status: string; count: string }>(); return rows.map((row) => ({ @@ -515,15 +553,19 @@ export class OverviewRepository { })); } - async getEmployeeGrowthTrend(days: number): Promise<{ date: string; count: number }[]> { + async getEmployeeGrowthTrend( + days: number, + ): Promise<{ date: string; count: number }[]> { const rows = await this.employeeRepository - .createQueryBuilder('employee') - .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, 'date') - .addSelect('COUNT(*)::int', 'count') - .where('employee.is_current = true') - .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { days }) - .groupBy('employee.created_at::date') - .orderBy('employee.created_at::date', 'ASC') + .createQueryBuilder("employee") + .select(`to_char(employee.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect("COUNT(*)::int", "count") + .where("employee.is_current = true") + .andWhere(`employee.created_at >= CURRENT_DATE - :days::int + 1`, { + days, + }) + .groupBy("employee.created_at::date") + .orderBy("employee.created_at::date", "ASC") .getRawMany<{ date: string; count: string }>(); return rows.map((row) => ({ @@ -538,16 +580,16 @@ export class OverviewRepository { where: { isActive: true, status: EUserStatus.ACCEPTED }, }), this.userRepository - .createQueryBuilder('user') - .where('user.is_active = false OR user.status != :status', { + .createQueryBuilder("user") + .where("user.is_active = false OR user.status != :status", { status: EUserStatus.ACCEPTED, }) .getCount(), ]); return [ - { label: 'Active', count: active }, - { label: 'Inactive', count: inactive }, + { label: "Active", count: active }, + { label: "Inactive", count: inactive }, ]; } } From cd1fa385a3be6498d4c98d3bbfc5dc06f9d696f1 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 18 Jun 2026 14:46:11 +0300 Subject: [PATCH 2/9] refactor: rm the unused ff client stuff --- .../modules/companies/companies.controller.ts | 226 +++++++++--------- .../src/modules/companies/companies.module.ts | 38 +-- .../modules/companies/companies.service.ts | 196 +++++++++------ .../companies/dto/create-ff-client.dto.ts | 24 -- .../companies/dto/response-ff-client.dto.ts | 23 -- .../companies/dto/update-ff-client.dto.ts | 4 - .../companies/entities/ff-client.entity.ts | 37 --- .../modules/companies/ff-client.repository.ts | 32 --- 8 files changed, 260 insertions(+), 320 deletions(-) delete mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts delete mode 100644 apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts delete mode 100644 apps/edr-freight-api/src/modules/companies/ff-client.repository.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 81fba19fb..684e9e9bd 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -1,22 +1,34 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common'; -import { AnyFilesInterceptor } from '@nestjs/platform-express'; -import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; -import { CurrentUser } from '@edr/api-common'; -import { FreightAdmin } from '../../common/booking-guards'; -import { FilesService } from '../files/files.service'; -import { CompaniesService } from './companies.service'; -import { CreateCompanyDto } from './dto/create-company.dto'; -import { UpdateCompanyDto } from './dto/update-company.dto'; -import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; -import { CreateFFClientDto } from './dto/create-ff-client.dto'; -import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; -import { ResponseCompanyDto } from './dto/response-company.dto'; -import { ResponseExternalProfileDto } from './dto/response-external-profile.dto'; -import { ResponseFFClientDto } from './dto/response-ff-client.dto'; -import { CompanyInfoResponseDto } from './dto/company-info-response.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ProfileResponseDto } from './dto/profile-response.dto'; -import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Query, + ParseUUIDPipe, + HttpCode, + HttpStatus, + UseInterceptors, + UploadedFiles, +} from "@nestjs/common"; +import { AnyFilesInterceptor } from "@nestjs/platform-express"; +import { ApiOperation, ApiTags, ApiConsumes } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import { FreightAdmin } from "../../common/booking-guards"; +import { FilesService } from "../files/files.service"; +import { CompaniesService } from "./companies.service"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { ResponseCompanyDto } from "./dto/response-company.dto"; +import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; +import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; interface CurrentIamUser { id: string; @@ -25,36 +37,47 @@ interface CurrentIamUser { phoneNumber?: string; } -@ApiTags('Companies') -@Controller('companies') +@ApiTags("Companies") +@Controller("companies") export class CompaniesController { constructor( private readonly companiesService: CompaniesService, private readonly filesService: FilesService, - ) {} + ) { } - @Get('getInfo') - @ApiOperation({ summary: 'Get company info for the current user' }) - async getInfo(@CurrentUser() user: CurrentIamUser): Promise { - const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + @Get("getInfo") + @ApiOperation({ summary: "Get company info for the current user" }) + async getInfo( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); return new CompanyInfoResponseDto(profile, company); } - @Get('profile') - @ApiOperation({ summary: 'Get flattened profile for the settings page' }) - async getProfile(@CurrentUser() user: CurrentIamUser): Promise { - const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id); + @Get("profile") + @ApiOperation({ summary: "Get flattened profile for the settings page" }) + async getProfile( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.getCompanyInfoByUserId(user.id); return new ProfileResponseDto(profile, company); } - @Get('dashboard') - @ApiOperation({ summary: 'Get portal dashboard KPIs (delivered, spend, freight volume) for the current user' }) - async getDashboard(@CurrentUser() user: CurrentIamUser): Promise { + @Get("dashboard") + @ApiOperation({ + summary: + "Get portal dashboard KPIs (delivered, spend, freight volume) for the current user", + }) + async getDashboard( + @CurrentUser() user: CurrentIamUser, + ): Promise { return this.companiesService.getDashboardSummary(user.id); } - @Patch('profile') - @ApiOperation({ summary: 'Update profile (flattened settings page)' }) + @Patch("profile") + @ApiOperation({ summary: "Update profile (flattened settings page)" }) async updateProfile( @CurrentUser() user: CurrentIamUser, @Body() dto: UpdateProfileDto, @@ -62,145 +85,134 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } - @Post('create') - @ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' }) + @Post("create") + @ApiOperation({ + summary: + "Create a company with its associated external profile (onboarding)", + }) async createWithProfile( @CurrentUser() user: CurrentIamUser, @Body() dto: CreateCompanyWithProfileDto, ): Promise { - const nameParts = (user.name?.en ?? '').split(' '); - const { profile, company } = await this.companiesService.createCompanyWithProfile( - { - userId: user.id, - firstName: nameParts[0] || '', - lastName: nameParts.slice(-1)[0] || '', - email: user.email ?? '', - phone: user.phoneNumber ?? '', - }, - dto, - ); + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = + await this.companiesService.createCompanyWithProfile( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto, + ); return new CompanyInfoResponseDto(profile, company); } @Post() @FreightAdmin() - @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) + @ApiOperation({ + summary: "Create a new company (customer, forwarder, transporter, broker)", + }) async create(@Body() dto: CreateCompanyDto): Promise { const company = await this.companiesService.createCompany(dto); return new ResponseCompanyDto(company); } @Get() - @ApiOperation({ summary: 'List all companies' }) + @ApiOperation({ summary: "List all companies" }) async findAll(): Promise { const companies = await this.companiesService.findAllCompanies(); return companies.map((c) => new ResponseCompanyDto(c)); } - @Get('type/:type') - @ApiOperation({ summary: 'Find companies by type' }) - async findByType(@Param('type') type: string): Promise { + @Get("type/:type") + @ApiOperation({ summary: "Find companies by type" }) + async findByType(@Param("type") type: string): Promise { const companies = await this.companiesService.findAllCompanies(); - return companies.filter((c) => c.type === type).map((c) => new ResponseCompanyDto(c)); + return companies + .filter((c) => c.type === type) + .map((c) => new ResponseCompanyDto(c)); } - @Get('search') - @ApiOperation({ summary: 'Search companies by name' }) - async search(@Query('name') name: string): Promise { + @Get("search") + @ApiOperation({ summary: "Search companies by name" }) + async search(@Query("name") name: string): Promise { const companies = await this.companiesService.findAllCompanies(); return companies .filter((c) => c.name.toLowerCase().includes(name.toLowerCase())) .map((c) => new ResponseCompanyDto(c)); } - @Get(':id') - @ApiOperation({ summary: 'Get company by ID' }) - async findById(@Param('id', ParseUUIDPipe) id: string): Promise { + @Get(":id") + @ApiOperation({ summary: "Get company by ID" }) + async findById( + @Param("id", ParseUUIDPipe) id: string, + ): Promise { const company = await this.companiesService.findCompanyById(id); return new ResponseCompanyDto(company); } - @Patch(':id') + @Patch(":id") @FreightAdmin() - @ApiOperation({ summary: 'Update a company' }) + @ApiOperation({ summary: "Update a company" }) async update( - @Param('id', ParseUUIDPipe) id: string, + @Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateCompanyDto, ): Promise { const company = await this.companiesService.updateCompany(id, dto); return new ResponseCompanyDto(company); } - @Delete(':id') + @Delete(":id") @FreightAdmin() - @ApiOperation({ summary: 'Soft-delete a company' }) + @ApiOperation({ summary: "Soft-delete a company" }) @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('id', ParseUUIDPipe) id: string): Promise { + async remove(@Param("id", ParseUUIDPipe) id: string): Promise { await this.companiesService.deleteCompany(id); } - @Post(':companyId/documents') + @Post(":companyId/documents") @UseInterceptors(AnyFilesInterceptor()) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload documents for a company (onboarding)' }) + @ApiConsumes("multipart/form-data") + @ApiOperation({ summary: "Upload documents for a company (onboarding)" }) async uploadDocuments( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, @UploadedFiles() files: Array, ) { - return this.filesService.uploadMany(companyId, 'companies', files); + return this.filesService.uploadMany(companyId, "companies", files); } - @Post(':companyId/profiles') + @Post(":companyId/profiles") @FreightAdmin() - @ApiOperation({ summary: 'Add a profile (employee) to a company' }) + @ApiOperation({ summary: "Add a profile (employee) to a company" }) async createProfile( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, @Body() dto: CreateExternalProfileDto, ): Promise { - const profile = await this.companiesService.createProfile({ ...dto, companyId }); + const profile = await this.companiesService.createProfile({ + ...dto, + companyId, + }); return new ResponseExternalProfileDto(profile); } - @Get(':companyId/profiles') - @ApiOperation({ summary: 'List profiles for a company' }) + @Get(":companyId/profiles") + @ApiOperation({ summary: "List profiles for a company" }) async listProfiles( - @Param('companyId', ParseUUIDPipe) companyId: string, + @Param("companyId", ParseUUIDPipe) companyId: string, ): Promise { - const profiles = await this.companiesService.findProfilesByCompany(companyId); + const profiles = + await this.companiesService.findProfilesByCompany(companyId); return profiles.map((p) => new ResponseExternalProfileDto(p)); } - @Get('profile/user/:userId') - @ApiOperation({ summary: 'Get profile by IAM user ID' }) + @Get("profile/user/:userId") + @ApiOperation({ summary: "Get profile by IAM user ID" }) async findProfileByUser( - @Param('userId', ParseUUIDPipe) userId: string, + @Param("userId", ParseUUIDPipe) userId: string, ): Promise { const profile = await this.companiesService.findProfileByUserId(userId); return new ResponseExternalProfileDto(profile); } - - @Post('ff-clients') - @FreightAdmin() - @ApiOperation({ summary: 'Link a forwarder to a client company' }) - async createFFClient(@Body() dto: CreateFFClientDto): Promise { - const client = await this.companiesService.createFFClient(dto); - return new ResponseFFClientDto(client); - } - - @Get(':forwarderCompanyId/clients') - @ApiOperation({ summary: 'List clients of a forwarder' }) - async listFFClients( - @Param('forwarderCompanyId', ParseUUIDPipe) forwarderCompanyId: string, - ): Promise { - const clients = await this.companiesService.findForwarderClients(forwarderCompanyId); - return clients.map((c) => new ResponseFFClientDto(c)); - } - - @Delete('ff-clients/:id') - @FreightAdmin() - @ApiOperation({ summary: 'Remove a forwarder-client relationship' }) - @HttpCode(HttpStatus.NO_CONTENT) - async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise { - await this.companiesService.deleteFFClient(id); - } } diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 406c4f509..6c809e8b7 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,21 +1,27 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { FilesModule } from '../files/files.module'; -import { CompaniesController } from './companies.controller'; -import { CompaniesService } from './companies.service'; -import { CompaniesRepository } from './companies.repository'; -import { ExternalProfileRepository } from './external-profile.repository'; -import { FFClientRepository } from './ff-client.repository'; -import { CompanyDashboardRepository } from './company-dashboard.repository'; -import { Company } from './entities/company.entity'; -import { ExternalProfile } from './entities/external-profile.entity'; -import { FFClient } from './entities/ff-client.entity'; -import { Booking } from '../bookings/entities/booking.entity'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { FilesModule } from "../files/files.module"; +import { CompaniesController } from "./companies.controller"; +import { CompaniesService } from "./companies.service"; +import { CompaniesRepository } from "./companies.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; +import { Booking } from "../bookings/entities/booking.entity"; @Module({ - imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient, Booking]), FilesModule], + imports: [ + TypeOrmModule.forFeature([Company, ExternalProfile, Booking]), + FilesModule, + ], controllers: [CompaniesController], - providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository, CompanyDashboardRepository], + providers: [ + CompaniesService, + CompaniesRepository, + ExternalProfileRepository, + CompanyDashboardRepository, + ], exports: [CompaniesService], }) -export class CompaniesModule {} +export class CompaniesModule { } diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 086e8d767..702c25d4e 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -1,19 +1,20 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { CompaniesRepository } from './companies.repository'; -import { ExternalProfileRepository } from './external-profile.repository'; -import { FFClientRepository } from './ff-client.repository'; -import { CompanyDashboardRepository } from './company-dashboard.repository'; -import { CreateCompanyDto } from './dto/create-company.dto'; -import { UpdateCompanyDto } from './dto/update-company.dto'; -import { CreateExternalProfileDto } from './dto/create-external-profile.dto'; -import { CreateFFClientDto } from './dto/create-ff-client.dto'; -import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto'; -import { UpdateProfileDto } from './dto/update-profile.dto'; -import { ProfileResponseDto } from './dto/profile-response.dto'; -import { DashboardSummaryResponseDto } from './dto/dashboard-summary-response.dto'; -import { Company } from './entities/company.entity'; -import { ExternalProfile } from './entities/external-profile.entity'; -import { FFClient } from './entities/ff-client.entity'; +import { + Injectable, + NotFoundException, + ConflictException, +} from "@nestjs/common"; +import { CompaniesRepository } from "./companies.repository"; +import { ExternalProfileRepository } from "./external-profile.repository"; +import { CompanyDashboardRepository } from "./company-dashboard.repository"; +import { CreateCompanyDto } from "./dto/create-company.dto"; +import { UpdateCompanyDto } from "./dto/update-company.dto"; +import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; +import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; +import { UpdateProfileDto } from "./dto/update-profile.dto"; +import { ProfileResponseDto } from "./dto/profile-response.dto"; +import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; +import { Company } from "./entities/company.entity"; +import { ExternalProfile } from "./entities/external-profile.entity"; export interface UserIdentity { userId: string; @@ -28,9 +29,8 @@ export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, private readonly profilesRepo: ExternalProfileRepository, - private readonly ffClientsRepo: FFClientRepository, private readonly dashboardRepo: CompanyDashboardRepository, - ) {} + ) { } async createCompany(dto: CreateCompanyDto): Promise { const exists = await this.companiesRepo.existsByTin(dto.tin); @@ -40,27 +40,34 @@ export class CompaniesService { return this.companiesRepo.create(dto); } - async createCompanyWithProfile(identity: UserIdentity, dto: CreateCompanyWithProfileDto): Promise<{ company: Company; profile: ExternalProfile }> { + async createCompanyWithProfile( + identity: UserIdentity, + dto: CreateCompanyWithProfileDto, + ): Promise<{ company: Company; profile: ExternalProfile }> { if (dto.tin) { const exists = await this.companiesRepo.existsByTin(dto.tin); if (exists) { - throw new ConflictException(`Company with TIN ${dto.tin} already exists`); + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); } } const existingProfile = await this.profilesRepo.findByEmail(identity.email); if (existingProfile) { - throw new ConflictException(`Profile with email ${identity.email} already exists`); + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); } const company = await this.companiesRepo.create({ name: dto.companyName, type: dto.companyType, - tin: dto.tin ?? '', + tin: dto.tin ?? "", vatNumber: dto.vatNumber ?? null, businessLicense: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null, - country: dto.companyLocation ?? 'Ethiopia', + country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, phone: dto.companyPhone ?? null, email: dto.companyEmail ?? null, @@ -82,7 +89,7 @@ export class CompaniesService { } async findAllCompanies(): Promise { - return this.companiesRepo.findAll({ order: { name: 'ASC' as any } }); + return this.companiesRepo.findAll({ order: { name: "ASC" as any } }); } async findCompanyById(id: string): Promise { @@ -91,12 +98,18 @@ export class CompaniesService { return company; } - async getCompanyInfoByUserId(userId: string): Promise<{ profile: ExternalProfile; company: Company }> { + async getCompanyInfoByUserId( + userId: string, + ): Promise<{ profile: ExternalProfile; company: Company }> { const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); const company = profile.company; - if (!company) throw new NotFoundException(`Company for profile ${profile.id} not found`); + if (!company) + throw new NotFoundException( + `Company for profile ${profile.id} not found`, + ); return { profile, company }; } @@ -114,7 +127,9 @@ export class CompaniesService { * column, so "delivered YTD" counts bookings created this year that reached a * delivered/completed status. */ - async getDashboardSummary(userId: string): Promise { + async getDashboardSummary( + userId: string, + ): Promise { // A user without a company profile has no bookings — return an empty summary // rather than 404, so the portal home still renders. const profile = await this.profilesRepo.findByUserId(userId); @@ -125,7 +140,9 @@ export class CompaniesService { const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); // Same point in the previous year, so YoY compares like-for-like windows. - const prevYearToDate = new Date(prevYearStart.getTime() + (now.getTime() - yearStart.getTime())); + const prevYearToDate = new Date( + prevYearStart.getTime() + (now.getTime() - yearStart.getTime()), + ); const [ deliveredThis, @@ -139,20 +156,36 @@ export class CompaniesService { this.dashboardRepo.countDelivered(companyId, yearStart, now), this.dashboardRepo.countCommitted(companyId, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), - this.dashboardRepo.sumPaidSpendByCurrency(companyId, prevYearStart, prevYearToDate), + this.dashboardRepo.sumPaidSpendByCurrency( + companyId, + prevYearStart, + prevYearToDate, + ), this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), - this.dashboardRepo.sumCommittedTonnage(companyId, prevYearStart, prevYearToDate), - this.dashboardRepo.monthlyCommittedTonnage(companyId, this.monthsAgo(now, 5), now), + this.dashboardRepo.sumCommittedTonnage( + companyId, + prevYearStart, + prevYearToDate, + ), + this.dashboardRepo.monthlyCommittedTonnage( + companyId, + this.monthsAgo(now, 5), + now, + ), ]); // Spend can span currencies; report the dominant one (prefer ETB on ties). const spend = this.pickCurrencyTotal(spendThisByCcy); - const spendPrev = spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; + const spendPrev = + spendPrevByCcy.find((c) => c.currency === spend.currency)?.total ?? 0; return { deliveredCount: deliveredThis, // Share of committed bookings that reached delivered/completed. - completionRate: committedThis > 0 ? Math.round((deliveredThis / committedThis) * 100) : 0, + completionRate: + committedThis > 0 + ? Math.round((deliveredThis / committedThis) * 100) + : 0, spendYtd: spend.total, spendCurrency: spend.currency, spendYtdChangePct: this.changePct(spend.total, spendPrev), @@ -172,12 +205,12 @@ export class CompaniesService { deliveredCount: 0, completionRate: 0, spendYtd: 0, - spendCurrency: 'ETB', + spendCurrency: "ETB", spendYtdChangePct: 0, freightVolume: { totalTonnes: 0, totalValue: 0, - currency: 'ETB', + currency: "ETB", ytdChangePct: 0, monthly: this.buildMonthlySeries(now, []), }, @@ -190,8 +223,11 @@ export class CompaniesService { } /** Pick the currency with the largest total, preferring ETB on ties / when empty. */ - private pickCurrencyTotal(totals: { currency: string; total: number }[]): { currency: string; total: number } { - if (totals.length === 0) return { currency: 'ETB', total: 0 }; + private pickCurrencyTotal(totals: { currency: string; total: number }[]): { + currency: string; + total: number; + } { + if (totals.length === 0) return { currency: "ETB", total: 0 }; return totals.reduce((best, cur) => (cur.total > best.total ? cur : best)); } @@ -206,13 +242,29 @@ export class CompaniesService { now: Date, rows: { year: number; month: number; tonnes: number }[], ): { month: string; tonnes: number }[] { - const labels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const labels = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ]; const byKey = new Map(rows.map((r) => [`${r.year}-${r.month}`, r.tonnes])); const series: { month: string; tonnes: number }[] = []; for (let i = 5; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const key = `${d.getFullYear()}-${d.getMonth() + 1}`; - series.push({ month: labels[d.getMonth()], tonnes: Math.round(byKey.get(key) ?? 0) }); + series.push({ + month: labels[d.getMonth()], + tonnes: Math.round(byKey.get(key) ?? 0), + }); } return series; } @@ -224,7 +276,10 @@ export class CompaniesService { return updated; } - async updateProfile(userId: string, dto: UpdateProfileDto): Promise { + async updateProfile( + userId: string, + dto: UpdateProfileDto, + ): Promise { const { profile, company } = await this.getCompanyInfoByUserId(userId); const companyUpdates: Record = {}; @@ -233,8 +288,10 @@ export class CompaniesService { if (dto.companyName !== undefined) companyUpdates.name = dto.companyName; if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail; if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone; - if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; - if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; + if (dto.companyLocation !== undefined) + companyUpdates.country = dto.companyLocation; + if (dto.companyAddress !== undefined) + companyUpdates.address = dto.companyAddress; if (dto.tin !== undefined) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { @@ -242,21 +299,28 @@ export class CompaniesService { companyUpdates.fanNumber = dto.fanNumber; } - if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; - if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; - if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName; - if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail; - if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone; + if (dto.contactPersonName !== undefined) + attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPhone !== undefined) + attrUpdates.contactPersonPhone = dto.contactPersonPhone; + if (dto.generalManagerName !== undefined) + attrUpdates.generalManagerName = dto.generalManagerName; + if (dto.generalManagerEmail !== undefined) + attrUpdates.generalManagerEmail = dto.generalManagerEmail; + if (dto.generalManagerPhone !== undefined) + attrUpdates.generalManagerPhone = dto.generalManagerPhone; if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; - if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; + if (dto.poaLocation !== undefined) + attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; companyUpdates.attributes = attrUpdates; const updated = await this.companiesRepo.update(company.id, companyUpdates); - if (!updated) throw new NotFoundException(`Company ${company.id} not found`); + if (!updated) + throw new NotFoundException(`Company ${company.id} not found`); return new ProfileResponseDto(profile, updated); } @@ -270,7 +334,9 @@ export class CompaniesService { const existing = await this.profilesRepo.findByEmail(dto.email); if (existing) { - throw new ConflictException(`Profile with email ${dto.email} already exists`); + throw new ConflictException( + `Profile with email ${dto.email} already exists`, + ); } return this.profilesRepo.create(dto); @@ -278,36 +344,12 @@ export class CompaniesService { async findProfileByUserId(userId: string): Promise { const profile = await this.profilesRepo.findByUserId(userId); - if (!profile) throw new NotFoundException(`Profile for user ${userId} not found`); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); return profile; } async findProfilesByCompany(companyId: string): Promise { return this.profilesRepo.findByCompanyId(companyId); } - - async createFFClient(dto: CreateFFClientDto): Promise { - await this.findCompanyById(dto.forwarderCompanyId); - await this.findCompanyById(dto.clientCompanyId); - - const existing = await this.ffClientsRepo.findRelationship( - dto.forwarderCompanyId, - dto.clientCompanyId, - ); - if (existing) { - throw new ConflictException('This forwarder-client relationship already exists'); - } - - return this.ffClientsRepo.create(dto); - } - - async findForwarderClients(forwarderCompanyId: string): Promise { - return this.ffClientsRepo.findByForwarder(forwarderCompanyId); - } - - async deleteFFClient(id: string): Promise { - const client = await this.ffClientsRepo.findById(id); - if (!client) throw new NotFoundException(`FFClient ${id} not found`); - await this.ffClientsRepo.softDelete(id); - } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts deleted file mode 100644 index 46375d7ea..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/create-ff-client.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IsUUID, IsNotEmpty, IsOptional, IsBoolean, IsEnum } from 'class-validator'; -import { FFClientRelationship } from '../entities/ff-client.entity'; - -export class CreateFFClientDto { - @IsUUID() - @IsNotEmpty() - forwarderCompanyId!: string; - - @IsUUID() - @IsNotEmpty() - clientCompanyId!: string; - - @IsOptional() - @IsEnum(FFClientRelationship) - relationshipType?: FFClientRelationship; - - @IsOptional() - @IsBoolean() - canBookOnBehalf?: boolean; - - @IsOptional() - @IsBoolean() - canViewDocuments?: boolean; -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts deleted file mode 100644 index 44a48069b..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/response-ff-client.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { FFClient, FFClientRelationship } from '../entities/ff-client.entity'; - -export class ResponseFFClientDto { - id: string; - forwarderCompanyId: string; - clientCompanyId: string; - relationshipType: FFClientRelationship; - canBookOnBehalf: boolean; - canViewDocuments: boolean; - createdAt: Date; - updatedAt: Date; - - constructor(client: FFClient) { - this.id = client.id; - this.forwarderCompanyId = client.forwarderCompanyId; - this.clientCompanyId = client.clientCompanyId; - this.relationshipType = client.relationshipType; - this.canBookOnBehalf = client.canBookOnBehalf; - this.canViewDocuments = client.canViewDocuments; - this.createdAt = client.createdAt; - this.updatedAt = client.updatedAt; - } -} diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts deleted file mode 100644 index a7ace689d..000000000 --- a/apps/edr-freight-api/src/modules/companies/dto/update-ff-client.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateFFClientDto } from './create-ff-client.dto'; - -export class UpdateFFClientDto extends PartialType(CreateFFClientDto) {} diff --git a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts deleted file mode 100644 index 136dea277..000000000 --- a/apps/edr-freight-api/src/modules/companies/entities/ff-client.entity.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, ManyToOne, JoinColumn, Unique } from 'typeorm'; -import { Company } from './company.entity'; - -export enum FFClientRelationship { - ManagedAccount = 'managed_account', - SubAgent = 'sub_agent', -} - -@Entity({ schema: 'freight', name: 'ff_clients' }) -@Unique(['forwarderCompanyId', 'clientCompanyId']) -@Index(['forwarderCompanyId']) -@Index(['clientCompanyId']) -export class FFClient extends BaseEntity { - @Column({ name: 'forwarder_company_id', type: 'uuid' }) - forwarderCompanyId!: string; - - @ManyToOne(() => Company) - @JoinColumn({ name: 'forwarder_company_id' }) - forwarderCompany!: Company; - - @Column({ name: 'client_company_id', type: 'uuid' }) - clientCompanyId!: string; - - @ManyToOne(() => Company) - @JoinColumn({ name: 'client_company_id' }) - clientCompany!: Company; - - @Column({ name: 'relationship_type', type: 'varchar', length: 32, default: FFClientRelationship.ManagedAccount }) - relationshipType!: FFClientRelationship; - - @Column({ name: 'can_book_on_behalf', type: 'boolean', default: true }) - canBookOnBehalf!: boolean; - - @Column({ name: 'can_view_documents', type: 'boolean', default: true }) - canViewDocuments!: boolean; -} diff --git a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts b/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts deleted file mode 100644 index b94cedec6..000000000 --- a/apps/edr-freight-api/src/modules/companies/ff-client.repository.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { BaseRepository } from '@edr/api-common'; -import { FFClient } from './entities/ff-client.entity'; - -@Injectable() -export class FFClientRepository extends BaseRepository { - constructor( - @InjectRepository(FFClient) - repo: Repository, - ) { - super(repo); - } - - async findByForwarder(forwarderCompanyId: string): Promise { - return this.repository.find({ where: { forwarderCompanyId } as any }); - } - - async findByClient(clientCompanyId: string): Promise { - return this.repository.find({ where: { clientCompanyId } as any }); - } - - async findRelationship( - forwarderCompanyId: string, - clientCompanyId: string, - ): Promise { - return this.repository.findOne({ - where: { forwarderCompanyId, clientCompanyId } as any, - }); - } -} From f3be5e3d848a084651b63be505a399872ed2c236 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 18 Jun 2026 15:32:44 +0300 Subject: [PATCH 3/9] feat(company): introduce CompanyProfile entity for detailed company roles --- .../1752000000000-CreateCompanyProfiles.ts | 103 ++++++++++++++++++ .../companies/dto/create-company.dto.ts | 5 - .../companies/dto/response-company.dto.ts | 2 - .../entities/company-profile.entity.ts | 62 +++++++++++ .../companies/entities/company.entity.ts | 99 +++++++++++------ 5 files changed, 230 insertions(+), 41 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts create mode 100644 apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts diff --git a/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts new file mode 100644 index 000000000..a27cef3ad --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000000-CreateCompanyProfiles.ts @@ -0,0 +1,103 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableIndex, + TableForeignKey, +} from "typeorm"; + +export class CreateCompanyProfiles1752000000000 implements MigrationInterface { + name = "CreateCompanyProfiles1752000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: "freight", + name: "company_profiles", + columns: [ + { + name: "id", + type: "uuid", + isPrimary: true, + generationStrategy: "uuid", + default: "gen_random_uuid()", + }, + { name: "company_id", type: "uuid" }, + { name: "type", type: "varchar", length: "32" }, + { name: "reference", type: "varchar", length: "20", isUnique: true }, + { + name: "status", + type: "varchar", + length: "32", + default: "'active'", + }, + { + name: "business_license", + type: "varchar", + length: "100", + isNullable: true, + }, + { name: "attributes", type: "jsonb", isNullable: true }, + { name: "created_at", type: "timestamptz", default: "now()" }, + { name: "updated_at", type: "timestamptz", default: "now()" }, + { name: "deleted_at", type: "timestamptz", isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + "freight.company_profiles", + new TableForeignKey({ + columnNames: ["company_id"], + referencedTableName: "companies", + referencedSchema: "freight", + referencedColumnNames: ["id"], + }), + ); + + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["company_id"] }), + ); + await queryRunner.createIndex( + "freight.company_profiles", + new TableIndex({ columnNames: ["type"] }), + ); + + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ex START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_im START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_ffe START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_fwj START WITH 1`, + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS freight.seq_company_profile_tr START WITH 1`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("freight.company_profiles"); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ex`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_im`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_ffe`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_fwj`, + ); + await queryRunner.query( + `DROP SEQUENCE IF EXISTS freight.seq_company_profile_tr`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts index b22334697..5718f541e 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company.dto.ts @@ -25,11 +25,6 @@ export class CreateCompanyDto { @MaxLength(50) vatNumber?: string; - @IsOptional() - @IsString() - @MaxLength(100) - businessLicense?: string; - @IsOptional() @IsString() @MaxLength(32) diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 1879e25d6..748fbf73d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -8,7 +8,6 @@ export class ResponseCompanyDto { status: CompanyStatus; tin: string; vatNumber?: string | null; - businessLicense?: string | null; fanNumber?: string | null; country: string; address?: string | null; @@ -27,7 +26,6 @@ export class ResponseCompanyDto { this.status = company.status; this.tin = company.tin; this.vatNumber = company.vatNumber; - this.businessLicense = company.businessLicense; this.fanNumber = company.fanNumber; this.country = company.country; this.address = company.address; diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts new file mode 100644 index 000000000..84da76135 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm"; +import { Company } from "./company.entity"; + +export enum ProfileType { + importer = "importer", + exporter = "exporter", + freightForwarder = "freight_forwarder", + djFreightForwarder = "dj_freight_forwarder", + transporter = "transporter", +} + +export enum ProfileStatus { + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", +} + +@Entity({ schema: "freight", name: "company_profiles" }) +@Index(["reference"], { unique: true }) +@Index(["type"]) +@Index(["companyId"]) +export class CompanyProfile extends BaseEntity { + @Column({ name: "company_id", type: "uuid" }) + companyId!: string; + + @ManyToOne(() => Company, (company) => company.companyProfiles) + @JoinColumn({ name: "company_id" }) + company!: Company; + + @Column({ name: "type", type: "varchar", length: 32, enum: ProfileType }) + type!: ProfileType; + + @Column({ + name: "reference", + type: "varchar", + length: 20, + nullable: false, + unique: true, + }) + reference!: string; + + @Column({ + name: "status", + type: "varchar", + length: 32, + default: ProfileStatus.Active, + }) + status!: ProfileStatus; + + @Column({ + name: "business_license", + type: "varchar", + length: 100, + nullable: true, + }) + businessLicense?: string | null; + + @Column({ name: "attributes", type: "jsonb", nullable: true }) + attributes?: Record | null; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index 070854eb8..ec578a3b7 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts @@ -1,79 +1,110 @@ -import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; -import { ExternalProfile } from './external-profile.entity'; +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index, OneToMany } from "typeorm"; +import { ExternalProfile } from "./external-profile.entity"; +import { CompanyProfile } from "./company-profile.entity"; export enum CompanyType { - Customer = 'customer', - Forwarder = 'forwarder', - Transporter = 'transporter', - Broker = 'broker', + Customer = "customer", + FreightForwarder = "freight_forwarder", + DJFreightForwarder = "dj_freight_forwarder", + Transporter = "transporter", } export enum CompanyStatus { - Active = 'active', - Pending = 'pending', - Suspended = 'suspended', - Blacklisted = 'blacklisted', + Active = "active", + Pending = "pending", + Suspended = "suspended", + Blacklisted = "blacklisted", } -@Entity({ schema: 'freight', name: 'companies' }) -@Index(['tin']) -@Index(['type']) +@Entity({ schema: "freight", name: "companies" }) +@Index(["tin"]) +@Index(["type"]) export class Company extends BaseEntity { - @Column({ name: 'name', type: 'varchar', length: 200 }) + @Column({ name: "name", type: "varchar", length: 200 }) name!: string; - @Column({ name: 'type', type: 'varchar', length: 32, enum: CompanyType }) + @Column({ name: "type", type: "varchar", length: 32, enum: CompanyType }) type!: CompanyType; - @Column({ name: 'status', type: 'varchar', length: 32, default: CompanyStatus.Pending }) + @Column({ + name: "status", + type: "varchar", + length: 32, + default: CompanyStatus.Pending, + }) status!: CompanyStatus; - @Column({ name: 'tin', type: 'varchar', length: 10, unique: true }) + @Column({ name: "tin", type: "varchar", length: 10, unique: true }) tin!: string; - @Column({ name: 'vat_number', type: 'varchar', length: 50, nullable: true }) + @Column({ name: "vat_number", type: "varchar", length: 50, nullable: true }) vatNumber?: string | null; - @Column({ name: 'business_license', type: 'varchar', length: 100, nullable: true }) - businessLicense?: string | null; - - @Column({ name: 'fan_number', type: 'varchar', length: 16, nullable: true }) + @Column({ name: "fan_number", type: "varchar", length: 16, nullable: true }) fanNumber?: string | null; - @Column({ name: 'country', type: 'varchar', length: 32, default: 'Ethiopia' }) + @Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" }) country!: string; - @Column({ name: 'address', type: 'text', nullable: true }) + @Column({ name: "address", type: "text", nullable: true }) address?: string | null; - @Column({ name: 'phone', type: 'varchar', length: 20, nullable: true }) + @Column({ name: "phone", type: "varchar", length: 20, nullable: true }) phone?: string | null; - @Column({ name: 'email', type: 'varchar', length: 150, nullable: true }) + @Column({ name: "email", type: "varchar", length: 150, nullable: true }) email?: string | null; - @Column({ name: 'contact_person_name', type: 'varchar', length: 100, nullable: true }) + @Column({ + name: "contact_person_name", + type: "varchar", + length: 100, + nullable: true, + }) contactPersonName?: string | null; - @Column({ name: 'contact_person_phone', type: 'varchar', length: 20, nullable: true }) + @Column({ + name: "contact_person_phone", + type: "varchar", + length: 20, + nullable: true, + }) contactPersonPhone?: string | null; - @Column({ name: 'general_manager_name', type: 'varchar', length: 100, nullable: true }) + @Column({ + name: "general_manager_name", + type: "varchar", + length: 100, + nullable: true, + }) generalManagerName?: string | null; - @Column({ name: 'general_manager_email', type: 'varchar', length: 150, nullable: true }) + @Column({ + name: "general_manager_email", + type: "varchar", + length: 150, + nullable: true, + }) generalManagerEmail?: string | null; - @Column({ name: 'general_manager_phone', type: 'varchar', length: 20, nullable: true }) + @Column({ + name: "general_manager_phone", + type: "varchar", + length: 20, + nullable: true, + }) generalManagerPhone?: string | null; - @Column({ name: 'website', type: 'varchar', length: 200, nullable: true }) + @Column({ name: "website", type: "varchar", length: 200, nullable: true }) website?: string | null; - @Column({ name: 'attributes', type: 'jsonb', nullable: true }) + @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; @OneToMany(() => ExternalProfile, (profile) => profile.company) profiles?: ExternalProfile[]; + + @OneToMany(() => CompanyProfile, (profile) => profile.company) + companyProfiles?: CompanyProfile[]; } From e9149242b2e57939270598b73fe15a00e67ee476 Mon Sep 17 00:00:00 2001 From: ghost2023 Date: Thu, 18 Jun 2026 16:37:18 +0300 Subject: [PATCH 4/9] feat: setup the company profiles --- .../contracts/contract-view-model.builder.ts | 4 +- ...2000000001-MoveBusinessLicenseToProfile.ts | 39 ++++++++ .../modules/companies/companies.controller.ts | 5 +- .../src/modules/companies/companies.module.ts | 5 +- .../modules/companies/companies.service.ts | 88 ++++++++++++++++++- .../companies/company-profile.repository.ts | 61 +++++++++++++ .../dto/create-company-with-profile.dto.ts | 21 ++++- .../companies/dto/response-company.dto.ts | 25 ++++++ .../portal/src/services/api.ts | 32 +++---- .../portal/src/services/companies.service.ts | 18 ++++ 10 files changed, 271 insertions(+), 27 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts create mode 100644 apps/edr-freight-api/src/modules/companies/company-profile.repository.ts diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 676e4f4ba..41a2f3b44 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -114,7 +114,9 @@ export class ContractViewModelBuilder { tinNumber: this.valueOrDash(booking.company?.tin), vatNumber: this.valueOrDash(booking.company?.vatNumber), fanNumber: this.valueOrDash(booking.company?.fanNumber), - businessLicense: this.valueOrDash(booking.company?.businessLicense), + businessLicense: this.valueOrDash( + booking.company?.companyProfiles?.[0]?.businessLicense, + ), }, provider: { name: 'Ethio-Djibouti Standard Gauge Railway Share Company', diff --git a/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts new file mode 100644 index 000000000..f15996773 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1752000000001-MoveBusinessLicenseToProfile.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MoveBusinessLicenseToProfile1752000000001 + implements MigrationInterface +{ + name = 'MoveBusinessLicenseToProfile1752000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE freight.company_profiles cp + SET business_license = c.business_license + FROM freight.companies c + WHERE cp.company_id = c.id AND c.business_license IS NOT NULL + `); + + await queryRunner.query( + `ALTER TABLE freight.companies DROP COLUMN IF EXISTS business_license`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.companies ADD COLUMN business_license varchar(100) NULL`, + ); + + await queryRunner.query(` + UPDATE freight.companies c + SET business_license = cp.business_license + FROM ( + SELECT DISTINCT ON (cp2.company_id) + cp2.company_id, cp2.business_license + FROM freight.company_profiles cp2 + WHERE cp2.business_license IS NOT NULL + ORDER BY cp2.company_id, cp2.created_at + ) cp + WHERE cp.company_id = c.id + `); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 684e9e9bd..8e74c7528 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -85,6 +85,7 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } + // Used by portal @Post("create") @ApiOperation({ summary: @@ -109,10 +110,12 @@ export class CompaniesController { return new CompanyInfoResponseDto(profile, company); } + // Used by backoffice @Post() @FreightAdmin() @ApiOperation({ - summary: "Create a new company (customer, forwarder, transporter, broker)", + summary: + "Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", }) async create(@Body() dto: CreateCompanyDto): Promise { const company = await this.companiesService.createCompany(dto); diff --git a/apps/edr-freight-api/src/modules/companies/companies.module.ts b/apps/edr-freight-api/src/modules/companies/companies.module.ts index 6c809e8b7..53d3de4c8 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -8,11 +8,13 @@ import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; +import { CompanyProfile } from "./entities/company-profile.entity"; import { Booking } from "../bookings/entities/booking.entity"; +import { CompanyProfileRepository } from "./company-profile.repository"; @Module({ imports: [ - TypeOrmModule.forFeature([Company, ExternalProfile, Booking]), + TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), FilesModule, ], controllers: [CompaniesController], @@ -20,6 +22,7 @@ import { Booking } from "../bookings/entities/booking.entity"; CompaniesService, CompaniesRepository, ExternalProfileRepository, + CompanyProfileRepository, CompanyDashboardRepository, ], exports: [CompaniesService], diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index 702c25d4e..ee0262413 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -2,8 +2,10 @@ import { Injectable, NotFoundException, ConflictException, + BadRequestException, } from "@nestjs/common"; import { CompaniesRepository } from "./companies.repository"; +import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { CreateCompanyDto } from "./dto/create-company.dto"; @@ -15,6 +17,11 @@ import { ProfileResponseDto } from "./dto/profile-response.dto"; import { DashboardSummaryResponseDto } from "./dto/dashboard-summary-response.dto"; import { Company } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; +import { + CompanyProfile, + ProfileType, + ProfileStatus, +} from "./entities/company-profile.entity"; export interface UserIdentity { userId: string; @@ -28,6 +35,7 @@ export interface UserIdentity { export class CompaniesService { constructor( private readonly companiesRepo: CompaniesRepository, + private readonly companyProfilesRepo: CompanyProfileRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, ) { } @@ -65,7 +73,6 @@ export class CompaniesService { type: dto.companyType, tin: dto.tin ?? "", vatNumber: dto.vatNumber ?? null, - businessLicense: dto.fanNumber ?? null, fanNumber: dto.fanNumber ?? null, country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, @@ -89,7 +96,7 @@ export class CompaniesService { } async findAllCompanies(): Promise { - return this.companiesRepo.findAll({ order: { name: "ASC" as any } }); + return this.companiesRepo.findAll({ order: { name: "ASC" } }); } async findCompanyById(id: string): Promise { @@ -111,6 +118,9 @@ export class CompaniesService { `Company for profile ${profile.id} not found`, ); + company.companyProfiles = + await this.companyProfilesRepo.findByCompanyId(company.id); + return { profile, company }; } @@ -295,7 +305,6 @@ export class CompaniesService { if (dto.tin !== undefined) companyUpdates.tin = dto.tin; if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { - companyUpdates.businessLicense = dto.fanNumber; companyUpdates.fanNumber = dto.fanNumber; } @@ -352,4 +361,77 @@ export class CompaniesService { async findProfilesByCompany(companyId: string): Promise { return this.profilesRepo.findByCompanyId(companyId); } + + private getProfileTypeForCompanyType(companyType: string): ProfileType[] { + switch (companyType) { + case "customer": + return [ProfileType.importer, ProfileType.exporter]; + case "freight_forwarder": + return [ProfileType.freightForwarder]; + case "dj_freight_forwarder": + return [ProfileType.djFreightForwarder]; + case "transporter": + return [ProfileType.transporter]; + default: + return []; + } + } + + async createCompanyProfile( + companyId: string, + profileType?: ProfileType, + ): Promise { + const company = await this.findCompanyById(companyId); + + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + const type = profileType ?? allowedTypes[0]; + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType(companyId, type); + if (existing) { + throw new ConflictException( + `Company already has a ${type} profile (${existing.reference})`, + ); + } + + const reference = await this.companyProfilesRepo.generateReference(type); + + return this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + async createDefaultProfilesForCompany( + companyId: string, + ): Promise { + const company = await this.findCompanyById(companyId); + const types = this.getProfileTypeForCompanyType(company.type); + + const profiles: CompanyProfile[] = []; + for (const type of types) { + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (!existing) { + profiles.push(await this.createCompanyProfile(companyId, type)); + } + } + + if (profiles.length === 0) { + throw new BadRequestException( + `Company of type "${company.type}" must have at least one operational profile`, + ); + } + + return profiles; + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts new file mode 100644 index 000000000..db7427112 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -0,0 +1,61 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; +import { BaseRepository } from "@edr/api-common"; +import { CompanyProfile, ProfileType } from "./entities/company-profile.entity"; + +const SEQUENCE_MAP: Record = { + [ProfileType.exporter]: "seq_company_profile_ex", + [ProfileType.importer]: "seq_company_profile_im", + [ProfileType.freightForwarder]: "seq_company_profile_ffe", + [ProfileType.djFreightForwarder]: "seq_company_profile_fwj", + [ProfileType.transporter]: "seq_company_profile_tr", +}; + +const PREFIX_MAP: Record = { + [ProfileType.exporter]: "EX", + [ProfileType.importer]: "IM", + [ProfileType.freightForwarder]: "FFE", + [ProfileType.djFreightForwarder]: "FWJ", + [ProfileType.transporter]: "TR", +}; + +@Injectable() +export class CompanyProfileRepository extends BaseRepository { + constructor( + @InjectRepository(CompanyProfile) + repo: Repository, + ) { + super(repo); + } + + async generateReference(type: ProfileType): Promise { + const seqName = SEQUENCE_MAP[type]; + const result = await this.repository.query( + `SELECT nextval('${seqName}') AS next_id`, + ); + const nextId = result[0].next_id as number; + const prefix = PREFIX_MAP[type]; + return `${prefix}-${String(nextId).padStart(5, "0")}`; + } + + async findByCompanyId(companyId: string): Promise { + return this.repository.find({ + where: { companyId }, + relations: ["company"], + }); + } + + async findByType( + companyId: string, + type: ProfileType, + ): Promise { + return this.repository.findOne({ + where: { companyId, type }, + }); + } + + async findByReference(reference: string): Promise { + return this.repository.findOne({ where: { reference } }); + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index 4287676d6..aa0bb72a2 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -1,5 +1,17 @@ -import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum } from 'class-validator'; +import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator'; +import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CompanyProfileInputDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} export class CreateCompanyWithProfileDto { @IsEnum(CompanyType) @@ -55,4 +67,11 @@ export class CreateCompanyWithProfileDto { @IsOptional() attributes?: Record; + + @IsOptional() + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CompanyProfileInputDto) + companyProfiles?: CompanyProfileInputDto[]; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index 748fbf73d..cb7777e8b 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -1,6 +1,29 @@ import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; +import { CompanyProfile } from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; +export class ResponseCompanyProfileDto { + id: string; + type: string; + reference: string; + status: string; + businessLicense?: string | null; + attributes?: Record | null; + createdAt: Date; + updatedAt: Date; + + constructor(profile: CompanyProfile) { + this.id = profile.id; + this.type = profile.type; + this.reference = profile.reference; + this.status = profile.status; + this.businessLicense = profile.businessLicense; + this.attributes = profile.attributes; + this.createdAt = profile.createdAt; + this.updatedAt = profile.updatedAt; + } +} + export class ResponseCompanyDto { id: string; name: string; @@ -16,6 +39,7 @@ export class ResponseCompanyDto { website?: string | null; attributes?: Record | null; profiles?: ResponseExternalProfileDto[]; + companyProfiles?: ResponseCompanyProfileDto[]; createdAt: Date; updatedAt: Date; @@ -34,6 +58,7 @@ export class ResponseCompanyDto { this.website = company.website; this.attributes = company.attributes; this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); + this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 32ef5bbc0..ea6616799 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -129,11 +129,10 @@ export const api = { }, bookings: { - list: endpoint>( - "bookings", - "list", - bookingsService.list, - ), + list: endpoint< + BookingListFilter | void, + PaginatedResponse + >("bookings", "list", bookingsService.list), get: endpoint<{ id: string }, Freight.IBooking>( "bookings", @@ -196,8 +195,14 @@ export const api = { getBookableSchedules: endpoint< { originYardId?: string; destinationYardId?: string }, Freight.BookableScheduleItem[] - >("train-scheduling", "bookableSchedules", ({ originYardId, destinationYardId }) => - bookingsService.getBookableSchedules({ originYardId, destinationYardId }), + >( + "train-scheduling", + "bookableSchedules", + ({ originYardId, destinationYardId }) => + bookingsService.getBookableSchedules({ + originYardId, + destinationYardId, + }), ), }, @@ -263,19 +268,6 @@ export const api = { ({ entity }) => fileUploadSettingsService.getByEntity(entity), ), - create: endpoint( - "file-upload-settings", - "create", - (payload) => fileUploadSettingsService.create(payload), - ), - - update: endpoint< - { id: string; dto: UpdateFileUploadSettingDto }, - FileUploadSetting - >("file-upload-settings", "update", ({ id, dto }) => - fileUploadSettingsService.update(id, dto), - ), - remove: endpoint<{ id: string }, void>( "file-upload-settings", "remove", diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 409d9045b..eef7f31fe 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -35,6 +35,18 @@ export interface CompanyResponse { email: string | null; website: string | null; attributes: Record | null; + companyProfiles?: CompanyProfileResponse[]; + createdAt: string; + updatedAt: string; +} + +export interface CompanyProfileResponse { + id: string; + type: string; + reference: string; + status: string; + businessLicense: string | null; + attributes: Record | null; createdAt: string; updatedAt: string; } @@ -44,6 +56,11 @@ export interface CompanyInfoResponse { company: CompanyResponse; } +export interface CompanyProfileInput { + type: "importer" | "exporter" | "freight_forwarder" | "dj_freight_forwarder" | "transporter"; + businessLicense?: string; +} + export interface CreateCompanyPayload { companyType?: string; companyName: string; @@ -57,6 +74,7 @@ export interface CreateCompanyPayload { jobTitle?: string; isPrimaryContact?: boolean; attributes?: Record; + companyProfiles?: CompanyProfileInput[]; } export interface FreightVolumePoint { From c6179c046ca149968e8875406020b6b1bb1e2d2b Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 18 Jun 2026 14:45:14 +0000 Subject: [PATCH 5/9] feat(wip): up the settings onboaridng flow --- .../modules/companies/companies.controller.ts | 22 +- .../modules/companies/companies.service.ts | 43 ++++ .../companies/dto/add-company-profiles.dto.ts | 9 + .../companies/dto/profile-response.dto.ts | 8 + .../portal/src/constants/URLS.ts | 1 + .../src/pages/settings/CompanyRolesCard.tsx | 220 ++++++++++++++++++ .../src/pages/settings/TabCompanyProfile.tsx | 16 +- .../portal/src/services/api.ts | 7 + .../portal/src/services/companies.service.ts | 10 + .../portal/src/types/profile.ts | 4 + apps/edr-landing/next-env.d.ts | 6 + 11 files changed, 339 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts create mode 100644 apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx create mode 100644 apps/edr-landing/next-env.d.ts diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 8e74c7528..ac2868ec3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -23,7 +23,11 @@ import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.dto"; -import { ResponseCompanyDto } from "./dto/response-company.dto"; +import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { + ResponseCompanyDto, + ResponseCompanyProfileDto, +} from "./dto/response-company.dto"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -85,6 +89,22 @@ export class CompaniesController { return this.companiesService.updateProfile(user.id, dto); } + @Post("company-profiles") + @ApiOperation({ + summary: + "Add operational profile(s) (importer/exporter/forwarder) to the current user's company", + }) + async addCompanyProfiles( + @CurrentUser() user: CurrentIamUser, + @Body() dto: AddCompanyProfilesDto, + ): Promise { + const profiles = await this.companiesService.addCompanyProfilesForUser( + user.id, + dto.types, + ); + return profiles.map((p) => new ResponseCompanyProfileDto(p)); + } + // Used by portal @Post("create") @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index ee0262413..dc68f1785 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -434,4 +434,47 @@ export class CompaniesService { return profiles; } + + /** + * Add operational profile(s) to the current user's company (portal settings). + * Add-only and idempotent: each requested type must be allowed for the + * company's type, profiles that already exist are skipped (not re-created or + * rejected), and the full updated list is returned. + */ + async addCompanyProfilesForUser( + userId: string, + types: ProfileType[], + ): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + + const companyId = profile.company?.id ?? profile.companyId; + const company = await this.findCompanyById(companyId); + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + + for (const type of types) { + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + const existing = await this.companyProfilesRepo.findByType( + companyId, + type, + ); + if (existing) continue; + + const reference = await this.companyProfilesRepo.generateReference(type); + await this.companyProfilesRepo.create({ + companyId, + type, + reference, + status: ProfileStatus.Active, + }); + } + + return this.companyProfilesRepo.findByCompanyId(companyId); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts new file mode 100644 index 000000000..838c42111 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/add-company-profiles.dto.ts @@ -0,0 +1,9 @@ +import { IsArray, IsEnum, ArrayMinSize } from "class-validator"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class AddCompanyProfilesDto { + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + types!: ProfileType[]; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index ee8ede34f..d6744e75f 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -1,9 +1,11 @@ import { Company } from '../entities/company.entity'; import { ExternalProfile } from '../entities/external-profile.entity'; +import { ResponseCompanyProfileDto } from './response-company.dto'; export class ProfileResponseDto { companyId: string; companyName: string; + companyType: string; companyEmail: string | null; companyPhone: string | null; companyLocation: string; @@ -12,6 +14,8 @@ export class ProfileResponseDto { vatNumber: string | null; fanNumber: string | null; + companyProfiles: ResponseCompanyProfileDto[]; + contactPersonName: string | null; contactPersonPhone: string | null; generalManagerName: string | null; @@ -29,6 +33,10 @@ export class ProfileResponseDto { constructor(profile: ExternalProfile, company: Company) { this.companyId = company.id; this.companyName = company.name; + this.companyType = company.type; + this.companyProfiles = + company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? + []; this.companyEmail = company.email ?? null; this.companyPhone = company.phone ?? null; this.companyLocation = company.country; diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a2a72655e..7c6683049 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -83,6 +83,7 @@ export const URL_CONSTANTS = { GET_INFO: "/api/companies/getInfo", CREATE: "/api/companies/create", PROFILE: "/api/companies/profile", + COMPANY_PROFILES: "/api/companies/company-profiles", DASHBOARD: "/api/companies/dashboard", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx new file mode 100644 index 000000000..8c448bfbd --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -0,0 +1,220 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + ArrowDownToLine, + ArrowUpFromLine, + Building2, + Check, + CheckCircle2, + Save, + XCircle, +} from "lucide-react"; +import { + Box, + Button, + Card, + Group, + SimpleGrid, + Text, + ThemeIcon, + Title, + UnstyledButton, +} from "@mantine/core"; +import { api } from "@/services/api"; +import type { ProfileResponse } from "@/types/profile"; + +interface RoleOption { + type: string; + label: string; + description: string; + icon: React.ReactNode; +} + +const CUSTOMER_ROLES: RoleOption[] = [ + { + type: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + type: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, +]; + +const FORWARDER_ROLES: RoleOption[] = [ + { + type: "freight_forwarder", + label: "Freight Forwarder", + description: "Handle cargo on behalf of importers and exporters.", + icon: , + }, +]; + +// dj_freight_forwarder and transporter are intentionally not exposed yet. +function rolesForCompanyType(companyType: string): RoleOption[] { + if (companyType === "customer") return CUSTOMER_ROLES; + if (companyType === "freight_forwarder") return FORWARDER_ROLES; + return []; +} + +interface CompanyRolesCardProps { + profile: ProfileResponse; +} + +export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { + const queryClient = useQueryClient(); + + const options = useMemo( + () => rolesForCompanyType(profile.companyType), + [profile.companyType], + ); + + // Roles already persisted (active + locked), keyed by type -> reference. + const activeByType = useMemo(() => { + const map = new Map(); + for (const p of profile.companyProfiles) map.set(p.type, p.reference); + return map; + }, [profile.companyProfiles]); + + const [selected, setSelected] = useState>(new Set()); + + const toggle = (type: string) => { + if (activeByType.has(type)) return; // add-only: active roles are locked + setSelected((prev) => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }; + + const mutation = useMutation({ + mutationFn: (types: string[]) => + api.companies.addCompanyProfiles.call({ types }), + onSuccess: () => { + setSelected(new Set()); + queryClient.invalidateQueries({ + queryKey: api.companies.getProfile.queryKey(), + }); + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const handleSave = () => { + if (selected.size === 0) return; + mutation.mutate(Array.from(selected)); + }; + + return ( + + + + Business Profile + + + {profile.companyType === "customer" + ? "Select the role(s) your company operates as — importer, exporter, or both." + : "Your company's operational role."} + + + {options.length === 0 ? ( + + Role management for this company type is coming soon. + + ) : ( + + {options.map((opt) => { + const isActive = activeByType.has(opt.type); + const isSelected = selected.has(opt.type); + const highlighted = isActive || isSelected; + return ( + toggle(opt.type)} + className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${ + highlighted + ? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!" + : "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft" + } ${isActive ? "cursor-default" : ""}`} + > + + + {opt.icon} + + + + {opt.label} + + + {opt.description} + + {isActive && ( + + Active · {activeByType.get(opt.type)} + + )} + + {highlighted && ( + + )} + + + ); + })} + + )} + + {options.length > 0 && ( + + + {mutation.isSuccess && ( + + + + Profile updated + + + )} + {mutation.isError && ( + + + + Failed to update profile + + + )} + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index f2ad48f2c..80e8d6ab7 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -18,6 +18,7 @@ import { api } from "@/services/api"; import PhoneInput from "@/components/auth/PhoneInput"; import type { ProfileResponse } from "@/types/profile"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import CompanyRolesCard from "./CompanyRolesCard"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -121,11 +122,13 @@ export default function TabCompanyProfile({ const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data); return ( - - - - Company Profile - + + {!isCreate && profile && } + + + + Company Profile + {isCreate ? "Enter your company registration details to get started" @@ -251,6 +254,7 @@ export default function TabCompanyProfile({ - + + ); } diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index ea6616799..80ebd4da0 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -36,6 +36,7 @@ import { } from "@/types/dropdownSettings"; import type { CompanyInfoResponse, + CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, } from "./companies.service"; @@ -126,6 +127,12 @@ export const api = { "getDashboard", companiesService.getDashboard, ), + + addCompanyProfiles: endpoint<{ types: string[] }, CompanyProfileResponse[]>( + "companies", + "addCompanyProfiles", + companiesService.addCompanyProfiles, + ), }, bookings: { diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index eef7f31fe..149705ec1 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -142,6 +142,16 @@ export const companiesService = { return unwrap(response.data); }, + addCompanyProfiles: async (payload: { + types: string[]; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILES, + payload, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index cab34ee23..ce8661828 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -1,6 +1,10 @@ +import type { CompanyProfileResponse } from "@/services/companies.service"; + export interface ProfileResponse { companyId: string; companyName: string; + companyType: string; + companyProfiles: CompanyProfileResponse[]; companyEmail: string | null; companyPhone: string | null; companyLocation: string; diff --git a/apps/edr-landing/next-env.d.ts b/apps/edr-landing/next-env.d.ts new file mode 100644 index 000000000..c4b7818fb --- /dev/null +++ b/apps/edr-landing/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 8e278a3bd6b7511b13c35367eab3a701a39e7987 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 19 Jun 2026 09:07:11 +0000 Subject: [PATCH 6/9] feat: add company selection to settings onboarding --- .../modules/companies/companies.service.ts | 28 +++++ .../src/pages/settings/CompanyRolesCard.tsx | 103 +++--------------- .../pages/settings/OnboardingRoleSelect.tsx | 77 +++++++++++++ .../portal/src/pages/settings/RoleCard.tsx | 75 +++++++++++++ .../src/pages/settings/TabCompanyProfile.tsx | 47 ++++++-- .../src/pages/settings/companyRoles.tsx | 39 +++++++ 6 files changed, 271 insertions(+), 98 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index dc68f1785..fe1bc5598 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -92,6 +92,34 @@ export class CompaniesService { isPrimaryContact: dto.isPrimaryContact ?? true, }); + // Persist the operational role(s) chosen during onboarding. Types are + // already constrained to the company type on the client; any that don't + // match are skipped defensively rather than failing the whole signup. + if (dto.companyProfiles?.length) { + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + for (const input of dto.companyProfiles) { + if (!allowedTypes.includes(input.type)) continue; + const existing = await this.companyProfilesRepo.findByType( + company.id, + input.type, + ); + if (existing) continue; + const reference = await this.companyProfilesRepo.generateReference( + input.type, + ); + await this.companyProfilesRepo.create({ + companyId: company.id, + type: input.type, + reference, + businessLicense: input.businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + company.companyProfiles = await this.companyProfilesRepo.findByCompanyId( + company.id, + ); + } + return { company, profile }; } diff --git a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx index 8c448bfbd..000dcc25e 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -1,65 +1,18 @@ import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; import { - ArrowDownToLine, - ArrowUpFromLine, - Building2, - Check, - CheckCircle2, - Save, - XCircle, -} from "lucide-react"; -import { - Box, Button, Card, Group, SimpleGrid, Text, - ThemeIcon, Title, - UnstyledButton, } from "@mantine/core"; import { api } from "@/services/api"; import type { ProfileResponse } from "@/types/profile"; - -interface RoleOption { - type: string; - label: string; - description: string; - icon: React.ReactNode; -} - -const CUSTOMER_ROLES: RoleOption[] = [ - { - type: "importer", - label: "Importer", - description: "Import goods into Ethiopia via the railway corridor.", - icon: , - }, - { - type: "exporter", - label: "Exporter", - description: "Export goods from Ethiopia via rail.", - icon: , - }, -]; - -const FORWARDER_ROLES: RoleOption[] = [ - { - type: "freight_forwarder", - label: "Freight Forwarder", - description: "Handle cargo on behalf of importers and exporters.", - icon: , - }, -]; - -// dj_freight_forwarder and transporter are intentionally not exposed yet. -function rolesForCompanyType(companyType: string): RoleOption[] { - if (companyType === "customer") return CUSTOMER_ROLES; - if (companyType === "freight_forwarder") return FORWARDER_ROLES; - return []; -} +import RoleCard from "./RoleCard"; +import { rolesForCompanyType } from "./companyRoles"; interface CompanyRolesCardProps { profile: ProfileResponse; @@ -131,49 +84,19 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { {options.map((opt) => { const isActive = activeByType.has(opt.type); - const isSelected = selected.has(opt.type); - const highlighted = isActive || isSelected; return ( - toggle(opt.type)} - className={`group block rounded-lg border! p-5! text-left transition-all duration-200 ${ - highlighted - ? "border-[var(--mantine-color-edr-green-5)]! bg-edr-soft!" - : "border-edr-border! bg-edr-card! hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft" - } ${isActive ? "cursor-default" : ""}`} - > - - - {opt.icon} - - - - {opt.label} - - - {opt.description} - - {isActive && ( - - Active · {activeByType.get(opt.type)} - - )} - - {highlighted && ( - - )} - - + /> ); })} diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx new file mode 100644 index 000000000..faa9e7377 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -0,0 +1,77 @@ +import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Building2 } from "lucide-react"; +import RoleCard from "./RoleCard"; +import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles"; + +interface OnboardingRoleSelectProps { + /** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */ + value: string[]; + onChange: (next: string[]) => void; +} + +/** + * First (and only) thing shown in the Company Profile tab during onboarding. + * Importer / Exporter sit side by side and can both be picked; Freight + * Forwarder is a separate, mutually-exclusive choice below them. A valid + * selection reveals the company-profile fields. + */ +export default function OnboardingRoleSelect({ + value, + onChange, +}: OnboardingRoleSelectProps) { + const selected = new Set(value); + const isForwarder = selected.has(FREIGHT_FORWARDER.type); + + // Toggling a customer role drops any forwarder selection (mutually exclusive). + const toggleCustomerRole = (type: string) => { + const next = new Set(value.filter((t) => t !== FREIGHT_FORWARDER.type)); + if (next.has(type)) next.delete(type); + else next.add(type); + onChange([...next]); + }; + + const toggleForwarder = () => { + onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]); + }; + + return ( + + + + What does your company do? + + + Pick Importer, Exporter, or both — or register as a Freight Forwarder. + + + + {CUSTOMER_ROLES.map((role) => ( + toggleCustomerRole(role.type)} + /> + ))} + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx new file mode 100644 index 000000000..2e507ff83 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/RoleCard.tsx @@ -0,0 +1,75 @@ +import { Box, Group, Text, ThemeIcon, UnstyledButton } from "@mantine/core"; +import { Check } from "lucide-react"; + +export interface RoleCardProps { + label: string; + description: string; + icon: React.ReactNode; + /** Highlighted because the user just selected it (toggleable). */ + selected?: boolean; + /** Highlighted and non-interactive because it is already persisted. */ + locked?: boolean; + /** Small note under the description, e.g. "Active · IM-00001". */ + lockedNote?: string; + onClick?: () => void; +} + +/** + * The selectable company-role card used by both the onboarding role picker and + * the add-only roles card in settings. Visual mirror of the onboarding + * account-type cards. + */ +export default function RoleCard({ + label, + description, + icon, + selected = false, + locked = false, + lockedNote, + onClick, +}: RoleCardProps) { + const highlighted = selected || locked; + + return ( + + + + {icon} + + + + {label} + + + {description} + + {lockedNote && ( + + {lockedNote} + + )} + + {highlighted && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 80e8d6ab7..f02740664 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -17,8 +17,12 @@ import { import { api } from "@/services/api"; import PhoneInput from "@/components/auth/PhoneInput"; import type { ProfileResponse } from "@/types/profile"; -import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { + CreateCompanyPayload, + CompanyProfileInput, +} from "@/services/companies.service"; import CompanyRolesCard from "./CompanyRolesCard"; +import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -53,6 +57,7 @@ export default function TabCompanyProfile({ }: TabCompanyProfileProps) { const queryClient = useQueryClient(); const isCreate = mode === "create"; + const [selectedRoles, setSelectedRoles] = useState([]); const defaultValues = useMemo((): CompanyProfileFormData => { if (profile) { @@ -92,8 +97,7 @@ export default function TabCompanyProfile({ const mutation = useMutation({ mutationFn: async (data: CompanyProfileFormData) => { - const payload: CreateCompanyPayload = { - companyType: "customer", + const base = { companyName: data.companyName, companyEmail: data.companyEmail, companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, @@ -104,10 +108,21 @@ export default function TabCompanyProfile({ }; if (isCreate) { + // Importer/Exporter -> a "customer" company; Freight Forwarder is its + // own company type. Both drive the persisted CompanyProfile rows. + const companyType = selectedRoles.includes("freight_forwarder") + ? "freight_forwarder" + : "customer"; + const payload: CreateCompanyPayload = { + ...base, + companyType, + companyProfiles: selectedRoles.map((type) => ({ + type: type as CompanyProfileInput["type"], + })), + }; return api.companies.create.call(payload); - } else { - return api.companies.updateProfile.call(payload); } + return api.companies.updateProfile.call(base); }, onSuccess: () => { queryClient.invalidateQueries({ @@ -119,11 +134,26 @@ export default function TabCompanyProfile({ }, }); - const onSubmit = (data: CompanyProfileFormData) => mutation.mutate(data); + const onSubmit = (data: CompanyProfileFormData) => { + if (isCreate && selectedRoles.length === 0) return; + mutation.mutate(data); + }; + + // During onboarding the role selection gates the form: nothing else shows + // until the user picks Importer/Exporter or Freight Forwarder. + const showForm = !isCreate || selectedRoles.length > 0; return ( - {!isCreate && profile && } + {isCreate ? ( + + ) : ( + profile && + )} + {showForm && ( @@ -255,6 +285,7 @@ export default function TabCompanyProfile({ + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx new file mode 100644 index 000000000..d875d68af --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx @@ -0,0 +1,39 @@ +import { ArrowDownToLine, ArrowUpFromLine, Building2 } from "lucide-react"; + +export interface RoleMeta { + type: string; + label: string; + description: string; + icon: React.ReactNode; +} + +export const IMPORTER: RoleMeta = { + type: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , +}; + +export const EXPORTER: RoleMeta = { + type: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , +}; + +export const FREIGHT_FORWARDER: RoleMeta = { + type: "freight_forwarder", + label: "Freight Forwarder", + description: "Handle cargo on behalf of importers and exporters.", + icon: , +}; + +/** Importer / Exporter — the two roles a "customer" company can hold. */ +export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER]; + +// dj_freight_forwarder and transporter are intentionally not exposed yet. +export function rolesForCompanyType(companyType: string): RoleMeta[] { + if (companyType === "customer") return CUSTOMER_ROLES; + if (companyType === "freight_forwarder") return [FREIGHT_FORWARDER]; + return []; +} From 9284e72c141244db28ee09aa6220abac5641b29f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 19 Jun 2026 10:56:54 +0000 Subject: [PATCH 7/9] fix: document uploaded --- .../src/pages/settings/TabDocuments.tsx | 83 ++++++++++++++----- 1 file changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx index 4425c513e..a6e48393a 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabDocuments.tsx @@ -1,4 +1,16 @@ -import { useState } from "react"; +import { api } from "@/services/api"; +import { companiesService } from "@/services/companies.service"; +import { getMinFiles } from "@/types/fileUploadSettings"; +import type { ProfileResponse } from "@/types/profile"; +import { SmartFileInput } from "@edr/ui-common"; +import { + Button, + Card, + Center, + Group, + Text, + Title, +} from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, @@ -8,18 +20,7 @@ import { UploadCloud, XCircle, } from "lucide-react"; -import { - Card, - Group, - Title, - Text, - Button, - Center, -} from "@mantine/core"; -import { api } from "@/services/api"; -import { companiesService } from "@/services/companies.service"; -import { SmartFileInput } from "@edr/ui-common"; -import type { ProfileResponse } from "@/types/profile"; +import { useState } from "react"; interface TabDocumentsProps { profile: ProfileResponse; @@ -45,6 +46,42 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab }, }); + const [fieldErrors, setFieldErrors] = useState>({}); + + const handleFilesChange = (next: Record) => { + setDocumentFiles(next); + // Clear required-field errors for any field that now has a file. + setFieldErrors((prev) => { + if (Object.keys(prev).length === 0) return prev; + const updated = { ...prev }; + for (const key of Object.keys(updated)) { + const v = next[key]; + const hasValue = Array.isArray(v) ? v.length > 0 : v != null; + if (hasValue) delete updated[key]; + } + return updated; + }); + }; + + // Array-aware: an emptied multi-file field is `[]`, which must not count. + const hasFiles = Object.values(documentFiles).some((f) => + Array.isArray(f) ? f.length > 0 : f != null, + ); + + const validateRequired = (): Record => { + const errs: Record = {}; + for (const field of docSettingQuery.data?.fields ?? []) { + const min = getMinFiles(field); + if (min <= 0) continue; + const v = documentFiles[field.fileKey]; + const count = Array.isArray(v) ? v.length : v ? 1 : 0; + if (count < min) { + errs[field.fileKey] = `${field.fileLabel} is required`; + } + } + return errs; + }; + return ( @@ -67,7 +104,8 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab )} @@ -100,13 +138,14 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab leftSection={} loading={docUploadMutation.isPending} onClick={() => { - const hasFiles = Object.values(documentFiles).some((f) => f !== null); + const validationErrors = validateRequired(); + if (Object.keys(validationErrors).length > 0) { + setFieldErrors(validationErrors); + return; + } if (hasFiles) { docUploadMutation.mutate(documentFiles, { - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); - onContinue?.(); - }, + onSuccess: () => onContinue?.(), }); } else { onContinue?.(); @@ -120,7 +159,11 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab type="button" leftSection={} loading={docUploadMutation.isPending} - onClick={() => docUploadMutation.mutate(documentFiles)} + disabled={!hasFiles} + onClick={() => { + if (!hasFiles) return; + docUploadMutation.mutate(documentFiles); + }} > Upload Documents From 7ce5f2300555e5d2c324bc1612a7284e4ba5e71a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 19 Jun 2026 11:39:51 +0000 Subject: [PATCH 8/9] style: profile page ui improvment --- .../portal/src/pages/ProfilePage.tsx | 380 +++++++++++------- 1 file changed, 226 insertions(+), 154 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index 9c4354723..ef5c89bb6 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -1,17 +1,110 @@ -import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react"; -import { useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; -import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common"; +import { + Badge, + Box, + Button, + Card, + Center, + Container, + Divider, + Grid, + Group, + Loader, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { + Briefcase, + Building, + Building2, + FileCheck, + Globe, + Mail, + MapPin, + Phone, + ShieldCheck, + User, + UserCheck, +} from "lucide-react"; +import { Link } from "react-router-dom"; -function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) { +function InfoItem({ + icon, + label, + value, +}: { + icon?: React.ReactNode; + label: string; + value?: string | null; +}) { return ( -
- {icon &&
{icon}
} -
-

{label}

-

{value || "—"}

-
-
+ + {icon && ( + + {icon} + + )} + + + {label} + + + {value || "—"} + + + + ); +} + +function CardHeading({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) { + return ( + + + {icon} + + {title} + + + + {description} + + + ); +} + +function PersonnelGroup({ + color, + title, + children, +}: { + color: string; + title: string; + children: React.ReactNode; +}) { + return ( + + + + + {title} + + + + {children} + + ); } @@ -22,163 +115,142 @@ export default function ProfilePage() { if (isPending) { return ( -
-
-
+
+ +
); } if (!profile) { return ( -
-

No company profile found.

-
+
+ No company profile found. +
); } return ( -
-
-
- {/* Header */} -
-
- -
-
-
-

- {profile.companyName} -

- - Verified - -
-

- - {profile.companyName} -

-
-
+ + {/* Header */} + + + + + + + + {profile.companyName} + + + Verified + + + + + + {profile.companyType} + + + + - + -
- {/* Left Column */} -
-
- {/* Company Details */} - - - - - Company Details - - Business registration information - - - } label="Location" value={profile.companyLocation} /> - } label="Address" value={profile.companyAddress} /> - } label="TIN Number" value={profile.tinNumber} /> - } label="FAN Number" value={profile.fanNumber} /> - } label="Email" value={profile.companyEmail} /> - } label="Phone" value={profile.companyPhone} /> - - + + {/* Left Column */} + + + {/* Company Details */} + + } + title="Company Details" + description="Business registration information" + /> + + } label="Location" value={profile.companyLocation} /> + } label="Address" value={profile.companyAddress} /> + } label="TIN Number" value={profile.tinNumber} /> + } label="FAN Number" value={profile.fanNumber} /> + } label="Email" value={profile.companyEmail} /> + } label="Phone" value={profile.companyPhone} /> + + - {/* Personal Details (from ExternalProfile) */} - - - - - Profile Details - - Your linked user profile - - - } label="Profile" value="Primary Contact" /> - - -
+ {/* Key Personnel */} + + } + title="Key Personnel" + description="Management and contact persons" + /> + + + + + + + + + + + + - {/* Personnel Card */} - - - - - Key Personnel - - Management and contact persons - - -
-

- Contact Person -

-
- - -
-
-
-

- General Manager -

-
- - - -
-
-
+ {/* Power of Attorney */} + {profile.poaName && ( + + } + title="Power of Attorney" + description="Authorized representative details" + /> + + + + + + + )} + + - {/* Power of Attorney */} - {profile.poaName && ( - - - - - Power of Attorney - - Authorized representative details - - - - - - - - - )} -
- - {/* Right Column */} -
- -
- -
- -

Secure Account

-

- Your information is protected by enterprise-grade security. - Contact support for verified information updates. -

- -
-
-
-
-
-
-
+ {/* Right Column */} + + + + + + + + Secure Account + + + Your information is protected by enterprise-grade security. Contact + support for verified information updates. + + + + + + + ); } From 653f39ec65bf7722973686bce1d419a710bcdda9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 19 Jun 2026 12:25:42 +0000 Subject: [PATCH 9/9] feat: add the company profile support to the ui --- apps/edr-freight-web/portal/src/App.tsx | 4 +- .../portal/src/components/AppLayout.tsx | 39 +++ .../portal/src/pages/ProfilePage.tsx | 244 ++++++++++++++---- 3 files changed, 241 insertions(+), 46 deletions(-) diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index a4c2c8553..bbcdba42e 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -146,10 +146,11 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user } = useAuth(); + const { user, company } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; + const companyProfiles = company?.company?.companyProfiles ?? []; return ( @@ -190,6 +191,7 @@ const App = () => { enableThemeToggle userName={displayName} userEmail={userEmail} + companyProfiles={companyProfiles} > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 8d00baeb6..e0de92ec5 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -47,9 +47,19 @@ export interface AppLayoutProps { enableThemeToggle?: boolean; userName?: string; userEmail?: string; + /** Operational profiles for the company — surfaced as reference chips in the account menu. */ + companyProfiles?: { type: string; reference: string; status?: string }[]; children: ReactNode; } +const PROFILE_TYPE_LABELS: Record = { + importer: "Importer", + exporter: "Exporter", + freight_forwarder: "Freight Forwarder", + dj_freight_forwarder: "DJ Freight Forwarder", + transporter: "Transporter", +}; + function getInitials(name: string): string { return name .split(" ") @@ -106,6 +116,7 @@ export function AppLayout({ enableThemeToggle = false, userName = "User", userEmail, + companyProfiles = [], children, }: AppLayoutProps) { const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); @@ -306,6 +317,34 @@ export function AppLayout({ )} + {companyProfiles.length > 0 && ( + <> + + + + {companyProfiles.map((p) => ( + + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + {p.reference} + + + ))} + + + + )} } diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index ef5c89bb6..cf31d17a9 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -18,6 +18,7 @@ import { } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; import { + BadgeCheck, Briefcase, Building, Building2, @@ -26,11 +27,13 @@ import { Mail, MapPin, Phone, + Plus, ShieldCheck, User, UserCheck, } from "lucide-react"; import { Link } from "react-router-dom"; +import { rolesForCompanyType } from "./settings/companyRoles"; function InfoItem({ icon, @@ -129,6 +132,12 @@ export default function ProfilePage() { ); } + // Registered operational profiles keyed by type, plus the roles this company + // type may hold (importer/exporter for a customer). Mirrors CompanyRolesCard. + const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p])); + const roleOptions = rolesForCompanyType(profile.companyType); + const activeOptions = roleOptions.filter((o) => refByType.has(o.type)); + return ( {/* Header */} @@ -145,12 +154,28 @@ export default function ProfilePage() { Verified - - - - {profile.companyType} - - + {activeOptions.length > 0 ? ( + + {activeOptions.map((opt) => ( + + {opt.label} · {refByType.get(opt.type)!.reference} + + ))} + + ) : ( + + + + {profile.companyType} + + + )} @@ -163,24 +188,58 @@ export default function ProfilePage() { {/* Company Details */} } + icon={ + + } title="Company Details" description="Business registration information" /> - } label="Location" value={profile.companyLocation} /> - } label="Address" value={profile.companyAddress} /> - } label="TIN Number" value={profile.tinNumber} /> - } label="FAN Number" value={profile.fanNumber} /> - } label="Email" value={profile.companyEmail} /> - } label="Phone" value={profile.companyPhone} /> + } + label="Location" + value={profile.companyLocation} + /> + } + label="Address" + value={profile.companyAddress} + /> + } + label="TIN Number" + value={profile.tinNumber} + /> + } + label="FAN Number" + value={profile.fanNumber} + /> + } + label="Email" + value={profile.companyEmail} + /> + } + label="Phone" + value={profile.companyPhone} + /> {/* Key Personnel */} } + icon={ + + } title="Key Personnel" description="Management and contact persons" /> @@ -201,7 +260,12 @@ export default function ProfilePage() { {profile.poaName && ( } + icon={ + + } title="Power of Attorney" description="Authorized representative details" /> @@ -218,37 +282,127 @@ export default function ProfilePage() { {/* Right Column */} - - - - - - - Secure Account - - - Your information is protected by enterprise-grade security. Contact - support for verified information updates. - - + )} + + ); + })} + + )} + + + {/* Secure Account */} + + - Edit Settings - - - + + + + + Secure Account + + + Your information is protected by enterprise-grade security. + Contact support for verified information updates. + + + + +