From 54587a8c5575a3dc9e362655405aa91263ce8745 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 20 Jun 2026 08:28:01 +0000 Subject: [PATCH] feat: add nationality selection and business license upload functionality - Introduced a new step in the onboarding process to select company nationality (Ethiopian or Foreign). - Updated API and service layers to handle nationality data for companies. - Added support for uploading multiple business license files for each operational profile. - Refactored company and forwarder forms to include new license upload step. - Created a reusable RoleLicenseStep component for managing license file uploads. - Implemented utility functions for phone number handling. - Added migrations to update the database schema for nationality and business license files. --- apps/edr-freight-api/package.json | 1 + ...1791000000002-AddNationalityToCompanies.ts | 28 +++ ...ddBusinessLicenseFilesToCompanyProfiles.ts | 21 ++ .../modules/companies/companies.controller.ts | 32 +++ .../src/modules/companies/companies.module.ts | 2 + .../modules/companies/companies.service.ts | 94 +++++++- .../companies/company-profile.repository.ts | 2 +- .../companies/dto/profile-response.dto.ts | 2 + .../companies/dto/response-company.dto.ts | 22 +- .../companies/dto/start-onboarding.dto.ts | 8 +- .../companies/dto/update-profile.dto.ts | 7 +- .../entities/company-profile.entity.ts | 16 ++ .../companies/entities/company.entity.ts | 15 ++ .../src/scripts/seed-file-upload-settings.ts | 23 ++ .../src/seed/file-upload-settings.seeder.ts | 186 +++++++++++++--- .../portal/src/components/AppLayout.tsx | 30 ++- .../onboarding/OnboardingWizardDialog.tsx | 155 ++++++++----- .../components/onboarding/RoleLicenseStep.tsx | 129 +++++++++++ .../portal/src/constants/URLS.ts | 2 + .../portal/src/hooks/useAuth.ts | 8 +- .../src/pages/accounts/CompanyProfileForm.tsx | 205 ++++++++---------- .../src/pages/accounts/ForwarderForm.tsx | 146 ++++++++----- .../src/pages/settings/NationalitySelect.tsx | 50 +++++ .../portal/src/services/api.ts | 7 +- .../portal/src/services/companies.service.ts | 38 ++++ .../portal/src/types/profile.ts | 2 + .../edr-freight-web/portal/src/utils/phone.ts | 25 +++ 27 files changed, 978 insertions(+), 278 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts create mode 100644 apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts create mode 100644 apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts create mode 100644 apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx create 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 3fcde133e..074f227d9 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -17,6 +17,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" }, "dependencies": { diff --git a/apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts b/apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts new file mode 100644 index 000000000..1a05a9e45 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000002-AddNationalityToCompanies.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNationalityToCompanies1791000000002 + implements MigrationInterface +{ + name = "AddNationalityToCompanies1791000000002"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + ADD COLUMN IF NOT EXISTS nationality varchar(32); + `); + + // Existing companies default to Ethiopian (country defaults to Ethiopia). + await queryRunner.query(` + UPDATE freight.companies + SET nationality = 'ethiopian' + WHERE nationality IS NULL; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.companies + DROP COLUMN IF EXISTS nationality; + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts b/apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts new file mode 100644 index 000000000..d1e0412f0 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000003-AddBusinessLicenseFilesToCompanyProfiles.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddBusinessLicenseFilesToCompanyProfiles1791000000003 + implements MigrationInterface +{ + name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + ADD COLUMN IF NOT EXISTS business_license_files jsonb; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.company_profiles + DROP COLUMN IF EXISTS business_license_files; + `); + } +} 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 21cbfd67b..249a8e181 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -32,6 +32,7 @@ import { ResponseCompanyDto, ResponseCompanyProfileDto, } from "./dto/response-company.dto"; +import { BusinessLicenseFile } from "./entities/company-profile.entity"; import { ResponseExternalProfileDto } from "./dto/response-external-profile.dto"; import { CompanyInfoResponseDto } from "./dto/company-info-response.dto"; import { UpdateProfileDto } from "./dto/update-profile.dto"; @@ -129,6 +130,7 @@ export class CompaniesController { }, dto.companyType, dto.roles, + dto.nationality, ); return new CompanyInfoResponseDto(profile, company); } @@ -150,6 +152,36 @@ export class CompaniesController { return new ResponseCompanyProfileDto(profile); } + @Post("company-profiles/:profileId/license") + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Upload business-license document(s) for one of the current user's company profiles", + }) + async uploadProfileLicense( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + @UploadedFiles() files: Array, + ): Promise { + return this.companiesService.uploadProfileLicenseFiles( + user.id, + profileId, + files, + ); + } + + @Get("company-profiles/:profileId/license") + @ApiOperation({ + summary: "List business-license documents for a company profile", + }) + async listProfileLicense( + @CurrentUser() user: CurrentIamUser, + @Param("profileId", ParseUUIDPipe) profileId: string, + ): Promise { + return this.companiesService.listProfileLicenseFiles(user.id, profileId); + } + @Patch("active-mode") @ApiOperation({ summary: "Switch the current user's active operational mode (importer/exporter)", 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 53d3de4c8..edab8984d 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.module.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; import { FilesModule } from "../files/files.module"; +import { MinioModule } from "../minio/minio.module"; import { CompaniesController } from "./companies.controller"; import { CompaniesService } from "./companies.service"; import { CompaniesRepository } from "./companies.repository"; @@ -16,6 +17,7 @@ import { CompanyProfileRepository } from "./company-profile.repository"; imports: [ TypeOrmModule.forFeature([Company, ExternalProfile, CompanyProfile, Booking]), FilesModule, + MinioModule, ], controllers: [CompaniesController], providers: [ 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 dae561470..42f3446d1 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -8,6 +8,7 @@ import { CompaniesRepository } from "./companies.repository"; 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 { CreateCompanyDto } from "./dto/create-company.dto"; import { UpdateCompanyDto } from "./dto/update-company.dto"; import { CreateExternalProfileDto } from "./dto/create-external-profile.dto"; @@ -15,9 +16,15 @@ 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, CompanyStatus, CompanyType } from "./entities/company.entity"; +import { + Company, + CompanyNationality, + CompanyStatus, + CompanyType, +} from "./entities/company.entity"; import { ExternalProfile } from "./entities/external-profile.entity"; import { + BusinessLicenseFile, CompanyProfile, ProfileType, ProfileStatus, @@ -38,6 +45,7 @@ export class CompaniesService { private readonly companyProfilesRepo: CompanyProfileRepository, private readonly profilesRepo: ExternalProfileRepository, private readonly dashboardRepo: CompanyDashboardRepository, + private readonly minioService: MinioService, ) { } async createCompany(dto: CreateCompanyDto): Promise { @@ -151,12 +159,17 @@ export class CompaniesService { identity: UserIdentity, companyType: CompanyType, roles: ProfileType[], + nationality?: CompanyNationality, ): Promise<{ profile: ExternalProfile; company: Company }> { - // Already started — reuse the existing draft, just ensure roles exist. + // Already started — reuse the existing draft, just ensure roles exist and + // keep the nationality up to date if it was (re)selected. const existing = await this.profilesRepo.findByUserId(identity.userId); if (existing) { const companyId = existing.company?.id ?? existing.companyId; await this.ensureCompanyProfiles(companyId, companyType, roles); + if (nationality) { + await this.companiesRepo.update(companyId, { nationality }); + } return this.getCompanyInfoByUserId(identity.userId); } @@ -184,6 +197,7 @@ export class CompaniesService { type: companyType, tin: await this.generateDraftTin(), country: "Ethiopia", + nationality: nationality ?? CompanyNationality.Ethiopian, status: CompanyStatus.Pending, }); @@ -455,6 +469,8 @@ export class CompaniesService { const companyUpdates: Record = {}; const attrUpdates: Record = { ...(company.attributes ?? {}) }; + if (dto.nationality !== undefined) + 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; @@ -746,6 +762,17 @@ export class CompaniesService { ); } + // Every operational profile must have at least one business-license file + // (stored directly on the profile). + const profiles = await this.companyProfilesRepo.findByCompanyId(companyId); + for (const cp of profiles) { + if (!cp.businessLicenseFiles || cp.businessLicenseFiles.length === 0) { + throw new BadRequestException( + `Please upload a business license for your ${cp.type.replace(/_/g, " ")} profile before finishing.`, + ); + } + } + await this.profilesRepo.update(profile.id, { onboardingCompleted: true, onboardingStep: "done", @@ -756,6 +783,69 @@ export class CompaniesService { return this.getCompanyInfoByUserId(userId); } + /** + * Authorize and resolve a company_profile that must belong to the current + * user's company — used before accepting/returning its license files. + */ + async resolveOwnedProfile( + userId: string, + profileId: string, + ): Promise { + const { company } = await this.getCompanyInfoByUserId(userId); + const owned = (company.companyProfiles ?? []).find( + (p) => p.id === profileId, + ); + if (!owned) { + throw new NotFoundException(`Profile ${profileId} not found`); + } + return owned; + } + + /** + * Upload business-license document(s) and store them directly on the company + * profile (multi-file). Bytes go to object storage; only metadata/URLs are + * persisted on the profile — intentionally not via the FileRecord file model. + * New files are appended to any already present. Returns the full list. + */ + async uploadProfileLicenseFiles( + userId: string, + profileId: string, + files: Express.Multer.File[], + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + + const uploaded: BusinessLicenseFile[] = []; + for (const file of files) { + const objectName = `company_profiles/${profileId}/${Date.now()}_${file.originalname}`; + const url = await this.minioService.uploadFile( + objectName, + file.buffer, + file.mimetype, + ); + uploaded.push({ + name: file.originalname, + url, + size: file.size, + mimeType: file.mimetype, + }); + } + + const next = [...(profile.businessLicenseFiles ?? []), ...uploaded]; + await this.companyProfilesRepo.update(profileId, { + businessLicenseFiles: next, + }); + return next; + } + + /** The business-license files stored on a single company profile. */ + async listProfileLicenseFiles( + userId: string, + profileId: string, + ): Promise { + const profile = await this.resolveOwnedProfile(userId, profileId); + return profile.businessLicenseFiles ?? []; + } + /** * Resolve which company_profile a new booking belongs to, from the company * and the booking's trade direction. IMPORT → importer profile, EXPORT → diff --git a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts index db7427112..76d5ba96a 100644 --- a/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts +++ b/apps/edr-freight-api/src/modules/companies/company-profile.repository.ts @@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record = { const PREFIX_MAP: Record = { [ProfileType.exporter]: "EX", [ProfileType.importer]: "IM", - [ProfileType.freightForwarder]: "FFE", + [ProfileType.freightForwarder]: "FF", [ProfileType.djFreightForwarder]: "FWJ", [ProfileType.transporter]: "TR", }; 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 d6744e75f..993cf9f2b 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 @@ -6,6 +6,7 @@ export class ProfileResponseDto { companyId: string; companyName: string; companyType: string; + nationality: string | null; companyEmail: string | null; companyPhone: string | null; companyLocation: string; @@ -34,6 +35,7 @@ export class ProfileResponseDto { this.companyId = company.id; this.companyName = company.name; this.companyType = company.type; + this.nationality = company.nationality ?? null; this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ?? []; diff --git a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts index cb7777e8b..d6f3f1a2c 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/response-company.dto.ts @@ -1,5 +1,13 @@ -import { Company, CompanyType, CompanyStatus } from '../entities/company.entity'; -import { CompanyProfile } from '../entities/company-profile.entity'; +import { + Company, + CompanyType, + CompanyStatus, + CompanyNationality, +} from '../entities/company.entity'; +import { + BusinessLicenseFile, + CompanyProfile, +} from '../entities/company-profile.entity'; import { ResponseExternalProfileDto } from './response-external-profile.dto'; export class ResponseCompanyProfileDto { @@ -7,7 +15,10 @@ export class ResponseCompanyProfileDto { type: string; reference: string; status: string; + /** @deprecated Superseded by licenseFiles. Kept for back-compat. */ businessLicense?: string | null; + /** Business-license documents stored on the profile (multi-file). */ + licenseFiles: BusinessLicenseFile[]; attributes?: Record | null; createdAt: Date; updatedAt: Date; @@ -18,6 +29,7 @@ export class ResponseCompanyProfileDto { this.reference = profile.reference; this.status = profile.status; this.businessLicense = profile.businessLicense; + this.licenseFiles = profile.businessLicenseFiles ?? []; this.attributes = profile.attributes; this.createdAt = profile.createdAt; this.updatedAt = profile.updatedAt; @@ -29,6 +41,7 @@ export class ResponseCompanyDto { name: string; type: CompanyType; status: CompanyStatus; + nationality?: CompanyNationality | null; tin: string; vatNumber?: string | null; fanNumber?: string | null; @@ -48,6 +61,7 @@ export class ResponseCompanyDto { this.name = company.name; this.type = company.type; this.status = company.status; + this.nationality = company.nationality ?? null; this.tin = company.tin; this.vatNumber = company.vatNumber; this.fanNumber = company.fanNumber; @@ -58,7 +72,9 @@ export class ResponseCompanyDto { this.website = company.website; this.attributes = company.attributes; this.profiles = company.profiles?.map((p) => new ResponseExternalProfileDto(p)); - this.companyProfiles = company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)); + this.companyProfiles = company.companyProfiles?.map( + (p) => new ResponseCompanyProfileDto(p), + ); this.createdAt = company.createdAt; this.updatedAt = company.updatedAt; } 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 index edbe13145..7687faab1 100644 --- 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 @@ -1,5 +1,5 @@ -import { ArrayMinSize, IsArray, IsEnum } from "class-validator"; -import { CompanyType } from "../entities/company.entity"; +import { ArrayMinSize, IsArray, IsEnum, IsOptional } from "class-validator"; +import { CompanyNationality, CompanyType } from "../entities/company.entity"; import { ProfileType } from "../entities/company-profile.entity"; export class StartOnboardingDto { @@ -10,4 +10,8 @@ export class StartOnboardingDto { @ArrayMinSize(1) @IsEnum(ProfileType, { each: true }) roles!: ProfileType[]; + + @IsOptional() + @IsEnum(CompanyNationality) + nationality?: CompanyNationality; } 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 0acdf60a1..a3933ec9e 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,6 +1,11 @@ -import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator'; +import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator'; +import { CompanyNationality } from '../entities/company.entity'; export class UpdateProfileDto { + @IsOptional() + @IsEnum(CompanyNationality) + nationality?: CompanyNationality; + @IsOptional() @IsString() @MaxLength(200) diff --git a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts index 84da76135..c0cb41a63 100644 --- a/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts +++ b/apps/edr-freight-api/src/modules/companies/entities/company-profile.entity.ts @@ -17,6 +17,14 @@ export enum ProfileStatus { Blacklisted = "blacklisted", } +/** A business-license document stored directly on the company profile. */ +export interface BusinessLicenseFile { + name: string; + url: string; + size: number; + mimeType?: string; +} + @Entity({ schema: "freight", name: "company_profiles" }) @Index(["reference"], { unique: true }) @Index(["type"]) @@ -57,6 +65,14 @@ export class CompanyProfile extends BaseEntity { }) businessLicense?: string | null; + /** + * Business-license documents for this profile, stored directly on the profile + * (multi-file). The bytes live in object storage; only the metadata/URLs are + * persisted here — this is intentionally NOT modelled via the FileRecord table. + */ + @Column({ name: "business_license_files", type: "jsonb", nullable: true }) + businessLicenseFiles?: BusinessLicenseFile[] | null; + @Column({ name: "attributes", type: "jsonb", nullable: true }) attributes?: Record | null; } diff --git a/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts b/apps/edr-freight-api/src/modules/companies/entities/company.entity.ts index ec578a3b7..74a1e8fb9 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 @@ -17,6 +17,11 @@ export enum CompanyStatus { Blacklisted = "blacklisted", } +export enum CompanyNationality { + Ethiopian = "ethiopian", + Foreign = "foreign", +} + @Entity({ schema: "freight", name: "companies" }) @Index(["tin"]) @Index(["type"]) @@ -47,6 +52,16 @@ export class Company extends BaseEntity { @Column({ name: "country", type: "varchar", length: 32, default: "Ethiopia" }) country!: string; + /** Whether the company is Ethiopian or Foreign — drives the required onboarding documents. */ + @Column({ + name: "nationality", + type: "varchar", + length: 32, + nullable: true, + enum: CompanyNationality, + }) + nationality?: CompanyNationality | null; + @Column({ name: "address", type: "text", nullable: true }) address?: string | null; diff --git a/apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts b/apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts new file mode 100644 index 000000000..c334143b6 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-file-upload-settings.ts @@ -0,0 +1,23 @@ +import { AppDataSource } from "../data-source"; +import { FileUploadSettingsSeeder } from "../seed/file-upload-settings.seeder"; + +/** + * Idempotently (re)seed the company onboarding file-upload settings, including + * the nationality-based document sets (ethiopian / foreign). Run on demand: + * pnpm --filter @edr/freight-api seed:file-upload-settings + */ +async function run() { + await AppDataSource.initialize(); + try { + const seeder = new FileUploadSettingsSeeder(AppDataSource); + await seeder.run(); + console.log("Seeded company onboarding file-upload settings."); + } finally { + await AppDataSource.destroy(); + } +} + +run().catch((error) => { + console.error("Failed to seed file-upload settings:", error); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts index 2ef1f79f0..340a291db 100644 --- a/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts +++ b/apps/edr-freight-api/src/seed/file-upload-settings.seeder.ts @@ -4,33 +4,107 @@ import { DataSource } from "typeorm"; import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity"; import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity"; -const COMPANY_ONBOARDING_DOCUMENTS = [ - { - code: "company_onboarding_documents_customer", - label: "Customer onboarding documents", - entity: "customer", - }, - { - code: "company_onboarding_documents_forwarder", - label: "Forwarder onboarding documents", - entity: "other", - }, - { - code: "company_onboarding_documents_transporter", - label: "Transporter onboarding documents", - entity: "other", - }, - { - code: "company_onboarding_documents_forwarder_dj", - label: "Djibouti forwarder onboarding documents", - entity: "other", - }, -] as const; +interface OnboardingField { + fileKey: string; + fileLabel: string; + helpText: string; + isRequired: boolean; + isMultiple: boolean; + maxFiles: number; + allowedExtensions: string[]; + maxSizeMb: number; + displayOrder: number; +} -const COMPANY_ONBOARDING_DESCRIPTION = - "Required documents for external company onboarding. The same set applies to customers, forwarders, transporters, and brokers."; +const DOC_EXTENSIONS = ["pdf", "jpg", "jpeg", "png"]; -const COMPANY_ONBOARDING_FIELDS = [ +/** Documents required from an Ethiopian company at onboarding. */ +const ETHIOPIAN_ONBOARDING_FIELDS: OnboardingField[] = [ + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 1, + }, + { + fileKey: "commercial_license", + fileLabel: "Commercial License", + helpText: "Verified against the government trade system during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 2, + }, + { + fileKey: "national_id", + fileLabel: "National ID", + helpText: "Verified against the National ID API during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 3, + }, +]; + +/** Documents required from a Foreign company at onboarding. */ +const FOREIGN_ONBOARDING_FIELDS: OnboardingField[] = [ + { + fileKey: "tin_certificate", + fileLabel: "TIN Certificate", + helpText: "Verified against the TIN registry during registration.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 1, + }, + { + fileKey: "investment_license", + fileLabel: "Investment License", + helpText: "Investment license issued for operating in Ethiopia.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 2, + }, + { + fileKey: "national_id", + fileLabel: "National ID", + helpText: "National ID of the company's authorized representative.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 3, + }, + { + fileKey: "passport", + fileLabel: "Passport", + helpText: "Passport of the company's authorized representative.", + isRequired: true, + isMultiple: false, + maxFiles: 1, + allowedExtensions: DOC_EXTENSIONS, + maxSizeMb: 10, + displayOrder: 4, + }, +]; + +/** Legacy combined set, kept for the older per-company-type codes. */ +const LEGACY_ONBOARDING_FIELDS: OnboardingField[] = [ { fileKey: "business_license", fileLabel: "Business License / Trade License", @@ -38,7 +112,7 @@ const COMPANY_ONBOARDING_FIELDS = [ isRequired: true, isMultiple: false, maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + allowedExtensions: DOC_EXTENSIONS, maxSizeMb: 10, displayOrder: 1, }, @@ -49,7 +123,7 @@ const COMPANY_ONBOARDING_FIELDS = [ isRequired: true, isMultiple: false, maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + allowedExtensions: DOC_EXTENSIONS, maxSizeMb: 10, displayOrder: 2, }, @@ -60,11 +134,63 @@ const COMPANY_ONBOARDING_FIELDS = [ isRequired: true, isMultiple: false, maxFiles: 1, - allowedExtensions: ["pdf", "jpg", "jpeg", "png"], + allowedExtensions: DOC_EXTENSIONS, maxSizeMb: 10, displayOrder: 3, }, -] as const; +]; + +interface OnboardingDocumentSetting { + code: string; + label: string; + entity: string; + fields: OnboardingField[]; +} + +const COMPANY_ONBOARDING_DOCUMENTS: OnboardingDocumentSetting[] = [ + // Nationality-based sets — the document requirements depend only on whether + // the company is Ethiopian or Foreign (same for importer/exporter/forwarder). + { + code: "company_onboarding_documents_ethiopian", + label: "Ethiopian company onboarding documents", + entity: "customer", + fields: ETHIOPIAN_ONBOARDING_FIELDS, + }, + { + code: "company_onboarding_documents_foreign", + label: "Foreign company onboarding documents", + entity: "customer", + fields: FOREIGN_ONBOARDING_FIELDS, + }, + // Legacy per-company-type codes (kept for back-compat; no longer used by the portal). + { + code: "company_onboarding_documents_customer", + label: "Customer onboarding documents", + entity: "customer", + fields: LEGACY_ONBOARDING_FIELDS, + }, + { + code: "company_onboarding_documents_forwarder", + label: "Forwarder onboarding documents", + entity: "other", + fields: LEGACY_ONBOARDING_FIELDS, + }, + { + code: "company_onboarding_documents_transporter", + label: "Transporter onboarding documents", + entity: "other", + fields: LEGACY_ONBOARDING_FIELDS, + }, + { + code: "company_onboarding_documents_forwarder_dj", + label: "Djibouti forwarder onboarding documents", + entity: "other", + fields: LEGACY_ONBOARDING_FIELDS, + }, +]; + +const COMPANY_ONBOARDING_DESCRIPTION = + "Required documents for external company onboarding, by company nationality."; @Injectable() export class FileUploadSettingsSeeder { @@ -102,7 +228,7 @@ export class FileUploadSettingsSeeder { await fieldRepository.delete({ settingId: setting.id }); await fieldRepository.insert( - COMPANY_ONBOARDING_FIELDS.map((field, index) => ({ + documentSetting.fields.map((field, index) => ({ settingId: setting.id, fileKey: field.fileKey, fileLabel: field.fileLabel, diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index db415705d..5df4ca842 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -4,6 +4,7 @@ import { Box, Button, Divider, + FileInput, Group, Menu, Modal, @@ -11,7 +12,6 @@ import { ScrollArea, Stack, Text, - TextInput, UnstyledButton, useComputedColorScheme, useMantineColorScheme, @@ -31,6 +31,7 @@ import { Search, Settings, Sun, + Upload, User, X, } from "lucide-react"; @@ -68,7 +69,7 @@ export interface AppLayoutProps { /** Create the profile of the given type (with business license) then switch. */ onCreateProfile?: ( type: ImporterExporter, - businessLicense?: string, + licenseFiles: File[], ) => Promise | void; children: ReactNode; } @@ -183,7 +184,7 @@ export function AppLayout({ const [switching, setSwitching] = useState(false); const [createOpen, setCreateOpen] = useState(false); - const [businessLicense, setBusinessLicense] = useState(""); + const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); const handleSwitchClick = async () => { @@ -195,20 +196,21 @@ export function AppLayout({ setSwitching(false); } } else { - setBusinessLicense(""); + setLicenseFiles([]); setCreateError(null); setCreateOpen(true); } }; const handleCreateConfirm = async () => { + if (licenseFiles.length === 0) { + setCreateError("Please upload at least one business license file."); + return; + } setSwitching(true); setCreateError(null); try { - const res = await onCreateProfile?.( - targetMode, - businessLicense.trim() || undefined, - ); + const res = await onCreateProfile?.(targetMode, licenseFiles); if (res && !res.success) { setCreateError(res.error?.message ?? "Failed to create profile"); return; @@ -814,11 +816,15 @@ export function AppLayout({ {modeLabel(targetMode).toLowerCase()} mode. A new reference will be generated automatically. - setBusinessLicense(e.currentTarget.value)} + multiple + clearable + accept="application/pdf,image/png,image/jpeg" + leftSection={} + placeholder="Select license file(s)" + value={licenseFiles} + onChange={(files) => setLicenseFiles(files ?? [])} 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 index ddb046004..c400ee637 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,10 +1,11 @@ import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useRef, useState } from "react"; import useAuth from "@/hooks/useAuth"; import { api } from "@/services/api"; import type { + CompanyNationality, CreateCompanyPayload, ProfileTypeValue, } from "@/services/companies.service"; @@ -13,17 +14,19 @@ 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"; /** Form steps shared by CompanyProfileForm and ForwarderForm. */ -type FormStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +type FormStep = "company" | "personnel" | "poa" | "documents" | "additional"; const FORM_STEPS: FormStep[] = [ "company", "personnel", "poa", "documents", - "confirm", + "additional", ]; interface OnboardingWizardDialogProps { @@ -37,11 +40,11 @@ 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"; +/** Document upload setting code per company nationality. */ +function documentSettingCode(nationality: CompanyNationality): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; } /** @@ -60,16 +63,21 @@ export default function OnboardingWizardDialog({ const existingProfiles = company?.company?.companyProfiles ?? []; const companyAlreadyStarted = Boolean(company?.company?.id); + const savedNationality = + (company?.company?.nationality as CompanyNationality | null) ?? null; // Resume position from the backend-persisted step. const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep) ? (onboardingStep as FormStep) : "company"; - // 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", + // Phases: nationality → role → form. If a draft already exists, resume + // straight into the form with nationality + roles pre-selected. + const [phase, setPhase] = useState<"nationality" | "role" | "form">( + companyAlreadyStarted ? "form" : "nationality", + ); + const [nationality, setNationality] = useState( + savedNationality, ); const [roles, setRoles] = useState( existingProfiles.map((p) => p.type), @@ -77,8 +85,19 @@ export default function OnboardingWizardDialog({ const [documentFiles, setDocumentFiles] = useState< Record >({}); + // Newly-selected business-license files per company_profile id. + const [licenseFiles, setLicenseFiles] = useState>({}); const [startError, setStartError] = useState(null); + // Saved profile data, for rehydrating the form fields after a refresh. + const profileQuery = useQuery( + api.companies.getProfile.queryOptions({ + enabled: companyAlreadyStarted, + retry: false, + refetchOnWindowFocus: false, + }), + ); + const refreshInfo = useCallback( () => queryClient.invalidateQueries({ @@ -87,10 +106,13 @@ export default function OnboardingWizardDialog({ [queryClient], ); - // Begin onboarding: create the draft company + profile + role(s). + // Begin onboarding: create the draft company + profile + role(s) + nationality. const startMutation = useMutation({ - mutationFn: (vars: { companyType: string; roles: ProfileTypeValue[] }) => - api.companies.startOnboarding.call(vars), + mutationFn: (vars: { + companyType: string; + roles: ProfileTypeValue[]; + nationality?: CompanyNationality; + }) => api.companies.startOnboarding.call(vars), onSuccess: async () => { await refreshInfo(); setPhase("form"); @@ -98,19 +120,27 @@ export default function OnboardingWizardDialog({ onError: (err) => setStartError(extractApiError(err).message), }); - // Finalize: upload any documents, then mark onboarding complete. + // Finalize: upload per-role license files + company documents, then complete. const finishMutation = useMutation({ mutationFn: async () => { const companyId = company?.company?.id; - const hasFiles = Object.values(documentFiles).some( + // Per-role business licenses (file model, resource=company_profiles). + for (const [profileId, files] of Object.entries(licenseFiles)) { + if (files.length > 0) { + await companiesService.uploadProfileLicense(profileId, files); + } + } + // Nationality-based company documents (resource=companies). + const hasDocs = Object.values(documentFiles).some( (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), ); - if (companyId && hasFiles) { + if (companyId && hasDocs) { await companiesService.uploadDocuments(companyId, documentFiles); } return api.companies.completeOnboarding.call(); }, onSuccess: refreshInfo, + onError: (err) => setStartError(extractApiError(err).message), }); // Persist the resume step to the backend, but only ever move FORWARD — going @@ -124,13 +154,18 @@ export default function OnboardingWizardDialog({ api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); + const handleNationalityContinue = useCallback(() => { + if (nationality) setPhase("role"); + }, [nationality]); + const handleRolesContinue = useCallback(() => { setStartError(null); startMutation.mutate({ companyType: companyTypeForRoles(roles), roles: roles as ProfileTypeValue[], + nationality: nationality ?? undefined, }); - }, [roles, startMutation]); + }, [roles, nationality, startMutation]); // 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. @@ -166,7 +201,43 @@ export default function OnboardingWizardDialog({ 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); + // Documents depend on nationality; fall back to the saved one (resume) then ethiopian. + const effectiveNationality: CompanyNationality = + nationality ?? savedNationality ?? "ethiopian"; + + // Per-role license cards for the final step (from the created profiles). + const roleProfiles: RoleLicenseProfile[] = existingProfiles.map((p) => ({ + id: p.id, + type: p.type, + reference: p.reference, + existingFiles: p.licenseFiles ?? [], + })); + + const titleHint = + phase === "nationality" + ? "Where is your company registered?" + : phase === "role" + ? "Tell us what your company does to get started." + : "Set up your company profile to finish."; + + const formProps = { + documentSettingCode: documentSettingCode(effectiveNationality), + documentFiles, + onDocumentFilesChange: setDocumentFiles, + user, + onSubmit: handleSubmit, + isPending: finishMutation.isPending, + onBack: handleBackToRoles, + hideFirstStepBack: true, + initialStep: resumeFormStep, + resyncOpen: opened, + onStepChange: persistStep, + onSaveStep: saveStep, + rehydrate: profileQuery.data ?? null, + roleProfiles, + licenseFiles, + onLicenseChange: setLicenseFiles, + }; return ( - {phase === "role" - ? "Tell us what your company does to get started." - : "Set up your company profile to finish."} + {titleHint} } > - {phase === "role" ? ( + {phase === "nationality" ? ( + + + + + ) : phase === "role" ? ( {startError && ( @@ -210,35 +287,9 @@ export default function OnboardingWizardDialog({ /> ) : isForwarder ? ( - + ) : ( - + )} ); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx new file mode 100644 index 000000000..5206a4b32 --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/RoleLicenseStep.tsx @@ -0,0 +1,129 @@ +import { + Anchor, + Badge, + Card, + FileInput, + Group, + Stack, + Text, + ThemeIcon, +} from "@mantine/core"; +import { FileText, Paperclip, Upload } from "lucide-react"; + +import type { LicenseFile } from "@/services/companies.service"; + +const ROLE_LABELS: Record = { + importer: "Importer", + exporter: "Exporter", + freight_forwarder: "Freight Forwarder", + dj_freight_forwarder: "DJ Freight Forwarder", + transporter: "Transporter", +}; + +export interface RoleLicenseProfile { + id: string; + type: string; + reference: string; + /** License files already uploaded for this profile (rehydration). */ + existingFiles: LicenseFile[]; +} + +interface RoleLicenseStepProps { + /** One card per operational role/profile. */ + profiles: RoleLicenseProfile[]; + /** Newly-selected files per profile id (not yet uploaded). */ + value: Record; + onChange: (value: Record) => void; +} + +/** + * Final onboarding step: collect a business license (one or more files) for + * each operational role the company holds. Each role gets its own multi-file + * input; already-uploaded files are listed for context. + */ +export default function RoleLicenseStep({ + profiles, + value, + onChange, +}: RoleLicenseStepProps) { + const setFiles = (profileId: string, files: File[]) => { + onChange({ ...value, [profileId]: files }); + }; + + return ( + + + Upload the business license for each of your operational profiles. You + can attach more than one document per profile. + + + {profiles.map((profile) => { + const label = ROLE_LABELS[profile.type] ?? profile.type; + const selected = value[profile.id] ?? []; + const hasAny = selected.length > 0 || profile.existingFiles.length > 0; + + return ( + + + + + + +
+ + {label} — Business License + + + {profile.reference} + +
+
+ {hasAny && ( + + Provided + + )} +
+ + {profile.existingFiles.length > 0 && ( + + {profile.existingFiles.map((f) => ( + + + + {f.name} + + + ))} + + )} + + } + placeholder={ + profile.existingFiles.length > 0 + ? "Upload more / replace files" + : "Select license file(s)" + } + value={selected} + onChange={(files) => setFiles(profile.id, files ?? [])} + /> +
+ ); + })} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 3854ecdca..1c8bd2c5b 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -91,6 +91,8 @@ export const URL_CONSTANTS = { ONBOARDING_COMPLETE: "/api/companies/onboarding/complete", DASHBOARD: "/api/companies/dashboard", DOCUMENTS: (id: string) => `/api/companies/${id}/documents`, + PROFILE_LICENSE: (profileId: string) => + `/api/companies/company-profiles/${profileId}/license`, }, BOOKINGS: { diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index 3b3d84813..0f4f4e6a1 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -1,5 +1,6 @@ import { api } from "@/services/api"; import type { ProfileTypeValue } from "@/services/companies.service"; +import { companiesService } from "@/services/companies.service"; import type { LoginPayload, LoginResponse, @@ -187,10 +188,13 @@ const useAuth = () => { const createProfileAndSwitch = async ( type: ProfileTypeValue, - businessLicense?: string, + licenseFiles: File[], ): Promise> => { try { - await api.companies.createCompanyProfile.call({ type, businessLicense }); + const created = await api.companies.createCompanyProfile.call({ type }); + if (licenseFiles.length > 0) { + await companiesService.uploadProfileLicense(created.id, licenseFiles); + } await invalidateScopedData(); return { success: true, data: undefined }; } catch (err) { 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 2ce65b29e..3573d7ef6 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -30,12 +30,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 CompanyStep = "company" | "personnel" | "poa" | "documents" | "confirm"; +type CompanyStep = "company" | "personnel" | "poa" | "documents" | "additional"; const onboardingSchema = z.object({ companyName: z.string().min(1, "Company name is required"), @@ -90,7 +94,7 @@ const stepFields: Record = { ], poa: [], documents: [], - confirm: [], + additional: [], }; function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { @@ -159,6 +163,40 @@ function stepPayload(step: CompanyStep, d: FormData): Partial; @@ -192,6 +234,13 @@ export default function CompanyProfileForm({ 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); @@ -259,9 +308,10 @@ export default function CompanyProfileForm({ 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; @@ -284,13 +334,25 @@ export default function CompanyProfileForm({ } }; + // 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") { + 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("confirm"); + setStep("additional"); return; } // company / personnel / poa: validate + save before advancing. @@ -319,15 +381,15 @@ export default function CompanyProfileForm({ { 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: CompanyStep[] = [ @@ -335,7 +397,7 @@ export default function CompanyProfileForm({ "personnel", "poa", "documents", - "confirm", + "additional", ]; const currentIdx = stepOrder.indexOf(step); @@ -585,80 +647,12 @@ export default function CompanyProfileForm({ )} - {step === "confirm" && ( - - - Review your registration - - - Confirm the company details below before saving. - - - - - - - - - - - - - - - - - - - - - + {step === "additional" && ( + {})} + /> )} {saveError && ( @@ -666,7 +660,11 @@ export default function CompanyProfileForm({ color="red" variant="light" icon={} - title="Couldn't save this step" + title={ + step === "additional" + ? "Business license required" + : "Couldn't save this step" + } > {saveError} @@ -679,18 +677,14 @@ export default function CompanyProfileForm({ onClick={prevStep} leftSection={} > - {step === "confirm" ? "Back to Documents" : "Back"} + {step === "additional" ? "Back to Documents" : "Back"} ) : ( )}
@@ -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}`; +}