diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts new file mode 100644 index 000000000..07f0d2555 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts @@ -0,0 +1,109 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddETradeFieldsToCompanies1791000000003 + implements MigrationInterface +{ + name = "AddETradeFieldsToCompanies1791000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS licence_number varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS status_description text; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS date_registered varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS renewed_from varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS renewal_date varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS renewed_to varchar(50); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS region varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS zone varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS woreda varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS kebele varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS house_no varchar(100); + `); + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS etrade_phone varchar(20); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS licence_number; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS status_description; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS date_registered; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS renewed_from; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS renewal_date; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS renewed_to; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS region; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS zone; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS woreda; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS kebele; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS house_no; + `); + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS etrade_phone; + `); + } +} 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 249a8e181..18512ce09 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -38,6 +38,8 @@ 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 { FetchETradeDto } from "./dto/fetch-etrade.dto"; +import { ETradeResponseDto } from "./dto/etrade-response.dto"; interface CurrentIamUser { id: string; @@ -85,6 +87,15 @@ export class CompaniesController { return this.companiesService.getDashboardSummary(user.id); } + @Post("fetch-etrade-info") + @ApiOperation({ summary: "Fetch company info from eTrade by TIN" }) + async fetchETradeInfo( + @Body() dto: FetchETradeDto, + ): Promise { + const data = await this.companiesService.fetchETradeData(dto.tin); + return new ETradeResponseDto(data); + } + @Patch("profile") @ApiOperation({ summary: "Update profile (flattened settings page)" }) async updateProfile( 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 edab8984d..d275c57a7 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; +import { HttpModule } from "@nestjs/axios"; import { FilesModule } from "../files/files.module"; import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; @@ -12,10 +13,12 @@ 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"; +import { ETradeService } from "./services/etrade.service"; @Module({ imports: [ TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), + HttpModule, FilesModule, MinioModule, ], @@ -26,6 +29,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; ExternalProfileRepository, CompanyProfileRepository, CompanyDashboardRepository, + ETradeService, ], 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 42f3446d1..69c4c3cae 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -9,6 +9,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; import { ExternalProfileRepository } from "./external-profile.repository"; import { CompanyDashboardRepository } from "./company-dashboard.repository"; import { MinioService } from "../minio/minio.service"; +import { ETradeService } from "./services/etrade.service"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -46,6 +47,7 @@ export class CompaniesService { private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, private readonly minioService: MinioService, + private readonly etradeService: ETradeService, ) { } async createCompany(dto: CreateCompanyDto): Promise { @@ -496,6 +498,10 @@ export class CompaniesService { if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName; + if (dto.contactPersonPosition !== undefined) + attrUpdates.contactPersonPosition = dto.contactPersonPosition; + if (dto.contactPersonEmail !== undefined) + attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone; if (dto.generalManagerName !== undefined) @@ -511,6 +517,31 @@ export class CompaniesService { attrUpdates.poaLocation = dto.poaLocation; if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress; + if (dto.licenceNumber !== undefined) + companyUpdates.licenceNumber = dto.licenceNumber; + if (dto.statusDescription !== undefined) + companyUpdates.statusDescription = dto.statusDescription; + if (dto.dateRegistered !== undefined) + companyUpdates.dateRegistered = dto.dateRegistered; + if (dto.renewedFrom !== undefined) + companyUpdates.renewedFrom = dto.renewedFrom; + if (dto.renewalDate !== undefined) + companyUpdates.renewalDate = dto.renewalDate; + if (dto.renewedTo !== undefined) + companyUpdates.renewedTo = dto.renewedTo; + if (dto.region !== undefined) + companyUpdates.region = dto.region; + if (dto.zone !== undefined) + companyUpdates.zone = dto.zone; + if (dto.woreda !== undefined) + companyUpdates.woreda = dto.woreda; + if (dto.kebele !== undefined) + companyUpdates.kebele = dto.kebele; + if (dto.houseNo !== undefined) + companyUpdates.houseNo = dto.houseNo; + if (dto.etradePhone !== undefined) + companyUpdates.etradePhone = dto.etradePhone; + companyUpdates.attributes = attrUpdates; const updated = await this.companiesRepo.update(company.id, companyUpdates); @@ -892,4 +923,14 @@ export class CompaniesService { return null; } } + + async fetchETradeData(tin: string) { + const { businessInfo } = await this.etradeService.resolveCompanyData(tin); + if (!businessInfo) { + throw new BadRequestException( + "No business license found for this TIN. Please check the number and try again.", + ); + } + return this.etradeService.extractRegistrationData(businessInfo); + } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts new file mode 100644 index 000000000..200b69fee --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts @@ -0,0 +1,39 @@ +import { CompanyRegistrationData } from "@edr/types"; + +export class ETradeResponseDto implements CompanyRegistrationData { + licenceNumber!: string; + statusDescription!: string; + dateRegistered!: string; + renewedFrom!: string; + renewalDate!: string; + renewedTo!: string; + region!: string; + zone!: string; + woreda!: string; + kebele!: string; + houseNo!: string; + mobilePhone!: string; + regularPhone!: string; + managerName!: string; + managerEmail?: string; + managerPhone!: string; + + constructor(data: CompanyRegistrationData) { + this.licenceNumber = data.licenceNumber; + this.statusDescription = data.statusDescription; + this.dateRegistered = data.dateRegistered; + this.renewedFrom = data.renewedFrom; + this.renewalDate = data.renewalDate; + this.renewedTo = data.renewedTo; + this.region = data.region; + this.zone = data.zone; + this.woreda = data.woreda; + this.kebele = data.kebele; + this.houseNo = data.houseNo; + this.mobilePhone = data.mobilePhone; + this.regularPhone = data.regularPhone; + this.managerName = data.managerName; + this.managerEmail = data.managerEmail; + this.managerPhone = data.managerPhone; + } +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts new file mode 100644 index 000000000..2eb37c92d --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts @@ -0,0 +1,8 @@ +import { IsString, IsNotEmpty, Length } from "class-validator"; + +export class FetchETradeDto { + @IsString() + @IsNotEmpty() + @Length(10, 10, { message: "TIN must be exactly 10 digits" }) + tin!: string; +} 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 993cf9f2b..97f2d9f50 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 @@ -17,7 +17,22 @@ export class ProfileResponseDto { companyProfiles: ResponseCompanyProfileDto[]; + licenceNumber: string | null; + statusDescription: string | null; + dateRegistered: string | null; + renewedFrom: string | null; + renewalDate: string | null; + renewedTo: string | null; + region: string | null; + zone: string | null; + woreda: string | null; + kebele: string | null; + houseNo: string | null; + etradePhone: string | null; + contactPersonName: string | null; + contactPersonPosition: string | null; + contactPersonEmail: string | null; contactPersonPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; @@ -48,8 +63,23 @@ export class ProfileResponseDto { this.fanNumber = company.fanNumber ?? null; this.profileId = profile.id; + this.licenceNumber = company.licenceNumber ?? null; + this.statusDescription = company.statusDescription ?? null; + this.dateRegistered = company.dateRegistered ?? null; + this.renewedFrom = company.renewedFrom ?? null; + this.renewalDate = company.renewalDate ?? null; + this.renewedTo = company.renewedTo ?? null; + this.region = company.region ?? null; + this.zone = company.zone ?? null; + this.woreda = company.woreda ?? null; + this.kebele = company.kebele ?? null; + this.houseNo = company.houseNo ?? null; + this.etradePhone = company.etradePhone ?? null; + const attrs = company.attributes ?? {}; this.contactPersonName = attrs.contactPersonName ?? null; + this.contactPersonPosition = attrs.contactPersonPosition ?? null; + this.contactPersonEmail = attrs.contactPersonEmail ?? null; this.contactPersonPhone = attrs.contactPersonPhone ?? null; this.generalManagerName = attrs.generalManagerName ?? null; this.generalManagerEmail = attrs.generalManagerEmail ?? null; diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index a3933ec9e..8bb691a80 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -50,6 +50,14 @@ export class UpdateProfileDto { @IsString() contactPersonName?: string; + @IsOptional() + @IsString() + contactPersonPosition?: string; + + @IsOptional() + @IsEmail() + contactPersonEmail?: string; + @IsOptional() @IsString() contactPersonPhone?: string; @@ -85,4 +93,63 @@ export class UpdateProfileDto { @IsOptional() @IsString() poaAddress?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + licenceNumber?: string; + + @IsOptional() + @IsString() + statusDescription?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + dateRegistered?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + renewedFrom?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + renewalDate?: string; + + @IsOptional() + @IsString() + @MaxLength(50) + renewedTo?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + region?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + zone?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + woreda?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + kebele?: string; + + @IsOptional() + @IsString() + @MaxLength(100) + houseNo?: string; + + @IsOptional() + @IsString() + @MaxLength(20) + etradePhone?: string; } 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 74a1e8fb9..6702f9f7c 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 @@ -117,6 +117,67 @@ export class Company extends BaseEntity { @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; + @Column({ + name: "licence_number", + type: "varchar", + length: 100, + nullable: true, + }) + licenceNumber?: string | null; + + @Column({ name: "status_description", type: "text", nullable: true }) + statusDescription?: string | null; + + @Column({ + name: "date_registered", + type: "varchar", + length: 50, + nullable: true, + }) + dateRegistered?: string | null; + + @Column({ + name: "renewed_from", + type: "varchar", + length: 50, + nullable: true, + }) + renewedFrom?: string | null; + + @Column({ + name: "renewal_date", + type: "varchar", + length: 50, + nullable: true, + }) + renewalDate?: string | null; + + @Column({ + name: "renewed_to", + type: "varchar", + length: 50, + nullable: true, + }) + renewedTo?: string | null; + + @Column({ name: "region", type: "varchar", length: 100, nullable: true }) + region?: string | null; + + @Column({ name: "zone", type: "varchar", length: 100, nullable: true }) + zone?: string | null; + + @Column({ name: "woreda", type: "varchar", length: 100, nullable: true }) + woreda?: string | null; + + @Column({ name: "kebele", type: "varchar", length: 100, nullable: true }) + kebele?: string | null; + + @Column({ name: "house_no", type: "varchar", length: 100, nullable: true }) + houseNo?: string | null; + + @Column({ name: "etrade_phone", type: "varchar", length: 20, nullable: true }) + etradePhone?: string | null; + @OneToMany(() => ExternalProfile, (profile) => profile.company) profiles?: ExternalProfile[]; diff --git a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts new file mode 100644 index 000000000..95f927738 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -0,0 +1,102 @@ +import { Injectable, BadRequestException } from "@nestjs/common"; +import { HttpService } from "@nestjs/axios"; +import { firstValueFrom } from "rxjs"; +import { + ETradeCompanyInfo, + ETradeBusinessInfo, + CompanyRegistrationData, +} from "@edr/types"; + +@Injectable() +export class ETradeService { + private readonly baseUrl = "https://etrade.gov.et/api"; + private readonly referer = "https://etrade.gov.et/business-license-checker"; + + constructor(private readonly httpService: HttpService) {} + + async getCompanyInfoByTin(tin: string): Promise { + const url = `${this.baseUrl}/Registration/GetRegistrationInfoByTin/${tin}/en`; + try { + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { Referer: this.referer }, + }), + ); + return response.data; + } catch (error: any) { + throw new BadRequestException( + `Failed to fetch company info from eTrade: ${error.message}`, + ); + } + } + + async getBusinessByLicenseNo( + licenseNo: string, + tin: string, + ): Promise { + const url = `${this.baseUrl}/BusinessMain/GetBusinessByLicenseNo`; + try { + const response = await firstValueFrom( + this.httpService.get(url, { + params: { + LicenseNo: licenseNo, + Tin: tin, + Lang: "en", + }, + headers: { Referer: this.referer }, + }), + ); + return response.data; + } catch (error: any) { + throw new BadRequestException( + `Failed to fetch business info from eTrade: ${error.message}`, + ); + } + } + + async resolveCompanyData(tin: string): Promise<{ + companyInfo: ETradeCompanyInfo; + businessInfo: ETradeBusinessInfo | null; + }> { + const companyInfo = await this.getCompanyInfoByTin(tin); + + if (!companyInfo.Businesses || companyInfo.Businesses.length === 0) { + return { companyInfo, businessInfo: null }; + } + + const latestBusiness = companyInfo.Businesses[0]; + try { + const businessInfo = await this.getBusinessByLicenseNo( + latestBusiness.LicenceNumber, + tin, + ); + return { companyInfo, businessInfo }; + } catch { + return { companyInfo, businessInfo: null }; + } + } + + extractRegistrationData( + businessInfo: ETradeBusinessInfo, + ): CompanyRegistrationData { + const primaryManager = businessInfo.AssociateShortInfos?.[0]; + + return { + licenceNumber: businessInfo.LicenceNumber, + statusDescription: businessInfo.StatusDescription, + dateRegistered: businessInfo.DateRegistered, + renewedFrom: businessInfo.RenewedFrom, + renewalDate: businessInfo.RenewalDate, + renewedTo: businessInfo.RenewedTo, + region: businessInfo.AddressInfo?.Region || "", + zone: businessInfo.AddressInfo?.Zone || "", + woreda: businessInfo.AddressInfo?.Woreda || "", + kebele: businessInfo.AddressInfo?.Kebele || "", + houseNo: businessInfo.AddressInfo?.HouseNo || "", + mobilePhone: businessInfo.AddressInfo?.MobilePhone || "", + regularPhone: businessInfo.AddressInfo?.RegularPhone || "", + managerName: primaryManager?.ManagerNameEng || "", + managerPhone: primaryManager?.RegularPhone || "", + }; + } +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx new file mode 100644 index 000000000..15632fe1b --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -0,0 +1,96 @@ +import { + Alert, + Button, + Group, + Loader, + SimpleGrid, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"; +import { useETradeData } from "@/hooks/useETradeData"; +import type { CompanyRegistrationData } from "@edr/types"; + +interface ETradeInfoProps { + tin: string; + onDataLoaded: (data: CompanyRegistrationData) => void; +} + +export default function ETradeInfo({ tin, onDataLoaded }: ETradeInfoProps) { + const mutation = useETradeData(); + const isLoading = mutation.isPending; + const hasError = mutation.isError; + const hasData = mutation.data; + + const handleFetch = async () => { + if (!tin || tin.length !== 10) return; + const result = await mutation.mutateAsync(tin); + if (result) { + onDataLoaded(result); + } + }; + + const errorMessage = + hasError && mutation.error + ? (mutation.error as any).message || + "Failed to fetch company information. Please try again." + : null; + + return ( + + + + + + + {hasError && errorMessage && ( + } + color="red" + title="Failed to fetch data" + > + {errorMessage} + + )} + + {hasData && ( + } + color="green" + title="Company information loaded" + > + + + License: {hasData.licenceNumber} + + + Status: {hasData.statusDescription} + + {hasData.region && ( + + Location: {hasData.kebele}, {hasData.woreda},{" "} + {hasData.zone}, {hasData.region} + + )} + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index c400ee637..584735b3c 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -20,10 +20,17 @@ import NationalitySelect from "@/pages/settings/NationalitySelect"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; /** Form steps shared by CompanyProfileForm and ForwarderForm. */ -type FormStep = "company" | "personnel" | "poa" | "documents" | "additional"; +type FormStep = + | "company" + | "personnel" + | "contact" + | "poa" + | "documents" + | "additional"; const FORM_STEPS: FormStep[] = [ "company", "personnel", + "contact", "poa", "documents", "additional", diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 1c8bd2c5b..6a22ef954 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -90,6 +90,7 @@ export const URL_CONSTANTS = { ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", + FETCH_ETRADE_INFO: "/api/companies/fetch-etrade-info", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, PROFILE_LICENSE: (profileId: string) => `/api/companies/company-profiles/${profileId}/license`, diff --git a/apps/edr-freight-web/portal/src/hooks/useETradeData.ts b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts new file mode 100644 index 000000000..9fbce53d9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useETradeData.ts @@ -0,0 +1,16 @@ +import { useMutation } from "@tanstack/react-query"; +import { companiesService } from "@/services/companies.service"; +import { extractApiError } from "@/utils/result"; +import type { CompanyRegistrationData } from "@edr/types"; + +export function useETradeData() { + return useMutation({ + mutationFn: async (tin: string): Promise => { + return companiesService.fetchETradeInfo({ tin }); + }, + onError: (error) => { + const { message } = extractApiError(error); + console.error("eTrade fetch error:", message); + }, + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3573d7ef6..ecc8d38ec 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -2,6 +2,7 @@ import { Alert, Box, Button, + Checkbox, Divider, Group, Loader, @@ -23,6 +24,7 @@ import { FileText, UploadCloud, User, + UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; @@ -31,6 +33,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; +import type { CompanyRegistrationData } from "@edr/types"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -38,8 +41,15 @@ import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; +import ETradeInfo from "@/components/onboarding/ETradeInfo"; -type CompanyStep = "company" | "personnel" | "poa" | "documents" | "additional"; +type CompanyStep = + | "company" + | "personnel" + | "contact" + | "poa" + | "documents" + | "additional"; const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -54,7 +64,25 @@ const onboardingSchema = z.object({ .min(1, "VAT number is required") .length(10, "VAT number must be exactly 10 digits"), fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + licenceNumber: z.string().optional(), + statusDescription: z.string().optional(), + dateRegistered: z.string().optional(), + renewedFrom: z.string().optional(), + renewalDate: z.string().optional(), + renewedTo: z.string().optional(), + region: z.string().optional(), + zone: z.string().optional(), + woreda: z.string().optional(), + kebele: z.string().optional(), + houseNo: z.string().optional(), + etradePhone: z.string().optional(), contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPosition: z.string().optional(), + contactPersonEmail: z + .string() + .email("Invalid email address") + .optional() + .or(z.literal("")), contactPersonPhone: z.string().min(1, "Contact person phone is required"), contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), generalManagerName: z.string().min(1, "GM name is required"), @@ -82,16 +110,32 @@ const stepFields: Record = { "tinNumber", "vatNumber", "fanNumber", + "licenceNumber", + "statusDescription", + "dateRegistered", + "renewedFrom", + "renewalDate", + "renewedTo", + "region", + "zone", + "woreda", + "kebele", + "houseNo", + "etradePhone", ], personnel: [ - "contactPersonName", - "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode", ], + contact: [ + "contactPersonName", + "contactPersonPosition", + "contactPersonEmail", + "contactPersonPhone", + "contactPersonPhoneCountryCode", + ], poa: [], documents: [], additional: [], @@ -109,6 +153,8 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { fanNumber: data.fanNumber, attributes: { contactPersonName: data.contactPersonName, + contactPersonPosition: data.contactPersonPosition || undefined, + contactPersonEmail: data.contactPersonEmail || undefined, contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, @@ -138,15 +184,32 @@ function stepPayload(step: CompanyStep, d: FormData): Partial({ resolver: zodResolver(onboardingSchema), @@ -294,7 +372,21 @@ export default function CompanyProfileForm({ tinNumber: "", vatNumber: "", fanNumber: "", + licenceNumber: "", + statusDescription: "", + dateRegistered: "", + renewedFrom: "", + renewalDate: "", + renewedTo: "", + region: "", + zone: "", + woreda: "", + kebele: "", + houseNo: "", + etradePhone: "", contactPersonName: "", + contactPersonPosition: "", + contactPersonEmail: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", generalManagerName: "", @@ -312,8 +404,83 @@ export default function CompanyProfileForm({ values: rehydrate ? toFormValues(rehydrate) : undefined, }); + // The business owner/manager pulled from eTrade — powers "Use owner as + // manager" on the General Manager step. Null until a TIN lookup succeeds. + const [etradeOwner, setEtradeOwner] = useState<{ + name: string; + phone: string; + } | null>(null); + + // Mirror the two "copy from previous person" checkboxes so they can be + // re-toggled (re-checking re-pulls the latest values). + const [gmIsContact, setGmIsContact] = useState(false); + const [contactIsPoa, setContactIsPoa] = useState(false); + + const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + setValue("licenceNumber", data.licenceNumber); + setValue("statusDescription", data.statusDescription); + setValue("dateRegistered", data.dateRegistered); + setValue("renewedFrom", data.renewedFrom); + setValue("renewalDate", data.renewalDate); + setValue("renewedTo", data.renewedTo); + setValue("region", data.region); + setValue("zone", data.zone); + setValue("woreda", data.woreda); + setValue("kebele", data.kebele); + setValue("houseNo", data.houseNo); + setValue("etradePhone", data.regularPhone || data.mobilePhone); + setEtradeOwner({ + name: data.managerName, + phone: data.managerPhone || data.regularPhone || data.mobilePhone, + }); + }; + + /** Fill the General Manager from the eTrade business owner. */ + const useOwnerAsManager = () => { + if (!etradeOwner) return; + setValue("generalManagerName", etradeOwner.name); + const { number, countryCode } = splitPhone(etradeOwner.phone); + setValue("generalManagerPhone", number); + setValue("generalManagerPhoneCountryCode", countryCode); + }; + + /** Copy the General Manager into the Contact Person fields (toggleable). */ + const toggleGmAsContact = (checked: boolean) => { + setGmIsContact(checked); + if (!checked) return; + setValue("contactPersonName", watch("generalManagerName")); + setValue("contactPersonEmail", watch("generalManagerEmail")); + setValue("contactPersonPhone", watch("generalManagerPhone")); + setValue( + "contactPersonPhoneCountryCode", + watch("generalManagerPhoneCountryCode"), + ); + }; + + /** Copy the Contact Person into the PoA fields (toggleable, still editable). */ + const toggleContactAsPoa = (checked: boolean) => { + setContactIsPoa(checked); + if (!checked) return; + setValue("poaName", watch("contactPersonName")); + setValue("poaEmail", watch("contactPersonEmail")); + setValue("poaPhone", watch("contactPersonPhone")); + setValue("poaPhoneCountryCode", watch("contactPersonPhoneCountryCode")); + }; + const hasDocuments = Boolean(uploadSetting?.fields?.length); - const totalSteps = 5; + + // Single source of truth for step sequence — navigation, labels and the + // progress bar all derive from this so adding/removing a step is one edit. + const stepOrder: CompanyStep[] = [ + "company", + "personnel", + "contact", + "poa", + "documents", + "additional", + ]; + const totalSteps = stepOrder.length; + const currentIdx = stepOrder.indexOf(step); /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { @@ -351,55 +518,44 @@ export default function CompanyProfileForm({ handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } - if (step === "documents") { - setStep("additional"); - return; + // The documents step has nothing to persist; field steps validate + save + // before advancing. + if (step !== "documents") { + const ok = await saveCurrentStep(); + if (!ok) return; } - // company / personnel / poa: validate + save before advancing. - const ok = await saveCurrentStep(); - if (!ok) return; - setStep( - step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents", - ); + setStep(stepOrder[currentIdx + 1]); }; const prevStep = () => { setSaveError(null); - if (step === "company") onBack(); - else if (step === "personnel") setStep("company"); - else if (step === "poa") setStep("personnel"); - else if (step === "documents") setStep("poa"); - else setStep("documents"); + if (currentIdx === 0) onBack(); + else setStep(stepOrder[currentIdx - 1]); }; // Back is hidden on the first step during onboarding (can't return to role // selection); otherwise always available. const showBack = !(hideFirstStepBack && step === "company"); - const STEPS: { key: CompanyStep; icon: React.ReactNode }[] = [ - { key: "company", icon: }, - { key: "personnel", icon: }, - { key: "poa", icon: }, - { key: "documents", icon: }, - { key: "additional", icon: }, - ]; - - const STEP_LABELS: Record = { - company: `Step 1 of ${totalSteps} — Company Information`, - personnel: `Step 2 of ${totalSteps} — Personnel Details`, - poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, - documents: `Step 4 of ${totalSteps} — Upload Documents`, - additional: `Step 5 of ${totalSteps} — Business License`, + const STEP_ICONS: Record = { + company: , + personnel: , + contact: , + poa: , + documents: , + additional: , }; - const stepOrder: CompanyStep[] = [ - "company", - "personnel", - "poa", - "documents", - "additional", - ]; - const currentIdx = stepOrder.indexOf(step); + const STEP_TITLES: Record = { + company: "Company Information", + personnel: "General Manager", + contact: "Contact Person", + poa: "Power of Attorney (Optional)", + documents: "Upload Documents", + additional: "Business License", + }; + + const stepLabel = `Step ${currentIdx + 1} of ${totalSteps} — ${STEP_TITLES[step]}`; return ( <> @@ -421,7 +577,7 @@ export default function CompanyProfileForm({ className="relative max-w-lg mx-auto px-2" > - {STEPS.map(({ key, icon }, i) => { + {stepOrder.map((key, i) => { const done = i < currentIdx; const active = i === currentIdx; return done || active ? ( @@ -433,7 +589,7 @@ export default function CompanyProfileForm({ color="edr-green" className="relative z-10" > - {done ? : icon} + {done ? : STEP_ICONS[key]} ) : ( - {icon} + {STEP_ICONS[key]} ); })} - {STEP_LABELS[step]} + {stepLabel} @@ -520,38 +676,133 @@ export default function CompanyProfileForm({ error={errors.fanNumber?.message} {...register("fanNumber")} /> + + + + Fetch Company Information from eTrade + + + + {watch("licenceNumber") && ( + <> + + + Registration Details from eTrade + + + + + + + + + + + + + + + + Address Information + + + + + + + + + + + + + + + )} )} {step === "personnel" && ( <> - - Contact Person - - - - - - - - - - General Manager - + + + General Manager + + {etradeOwner && ( + + )} + )} + {step === "contact" && ( + <> + + Contact Person + + toggleGmAsContact(e.currentTarget.checked)} + /> + + + + + + + + + + )} + {step === "poa" && ( <> Power of Attorney details are optional. Fill them in if you have them, or skip to continue. + toggleContactAsPoa(e.currentTarget.checked)} + /> => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.FETCH_ETRADE_INFO, + payload, + ); + return unwrap(response.data); + }, }; diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index e2a551159..951a1f129 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -13,7 +13,21 @@ export interface ProfileResponse { tinNumber: string; vatNumber: string | null; fanNumber: string | null; + licenceNumber?: string | null; + statusDescription?: string | null; + dateRegistered?: string | null; + renewedFrom?: string | null; + renewalDate?: string | null; + renewedTo?: string | null; + region?: string | null; + zone?: string | null; + woreda?: string | null; + kebele?: string | null; + houseNo?: string | null; + etradePhone?: string | null; contactPersonName: string | null; + contactPersonPosition: string | null; + contactPersonEmail: string | null; contactPersonPhone: string | null; generalManagerName: string | null; generalManagerEmail: string | null; @@ -36,7 +50,21 @@ export interface UpdateProfilePayload { tin?: string; vatNumber?: string; fanNumber?: string; + licenceNumber?: string; + statusDescription?: string; + dateRegistered?: string; + renewedFrom?: string; + renewalDate?: string; + renewedTo?: string; + region?: string; + zone?: string; + woreda?: string; + kebele?: string; + houseNo?: string; + etradePhone?: string; contactPersonName?: string; + contactPersonPosition?: string; + contactPersonEmail?: string; contactPersonPhone?: string; generalManagerName?: string; generalManagerEmail?: string; diff --git a/packages/types/src/freight/etrade.ts b/packages/types/src/freight/etrade.ts new file mode 100644 index 000000000..9067569fb --- /dev/null +++ b/packages/types/src/freight/etrade.ts @@ -0,0 +1,79 @@ +export interface ETradeAddressInfo { + Region: string; + Zone: string; + Woreda: string; + Kebele: string; + HouseNo: string; + MobilePhone: string; + RegularPhone: string; +} + +export interface ETradeAssociateInfo { + Position: string | null; + ManagerName: string; + ManagerNameEng: string; + Photo: string | null; + MobilePhone: string | null; + RegularPhone: string | null; +} + +export interface ETradeBusinessInfo { + MainGuid: string; + OwnerTIN: string; + DateRegistered: string; + TradeName: string; + LicenceNumber: string; + Status: number; + StatusDescription: string; + Capital: number; + AssociateShortInfos: ETradeAssociateInfo[]; + AddressInfo: ETradeAddressInfo; + RenewedTo: string; + RenewedToDateString: string; + RenewalDate: string; + RenewedFrom: string; + CancellationDate: string | null; +} + +export interface ETradeCompanyInfo { + Tin: string; + LegalCondtion: string; + RegNo: string; + RegDate: string; + BusinessName: string; + BusinessNameAmh: string; + PaidUpCapital: number; + AssociateShortInfos: ETradeAssociateInfo[]; + Businesses: Array<{ + MainGuid: string; + OwnerTIN: string; + DateRegistered: string; + TradeNameAmh: string; + TradesName: string; + LicenceNumber: string; + RenewalDate: string; + RenewedFrom: string; + RenewedTo: string; + BusinessLicensingGroupMain: string | null; + SubGroups: string | null; + }>; +} + +export interface CompanyRegistrationData { + licenceNumber: string; + statusDescription: string; + dateRegistered: string; + renewedFrom: string; + renewalDate: string; + renewedTo: string; + region: string; + zone: string; + woreda: string; + kebele: string; + houseNo: string; + mobilePhone: string; + regularPhone: string; + managerName: string; + managerEmail?: string; + managerPhone: string; +} diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 983dc69be..07f99f65b 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -3,6 +3,7 @@ import type { BaseEntity } from "../common"; export * from "./dropdown_settings"; export * from "./file_upload_settings"; export * from "./overview"; +export * from "./etrade"; export enum TradeDirection { IMPORT = "IMPORT",