mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
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.
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddNationalityToCompanies1791000000002
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddNationalityToCompanies1791000000002";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.companies
|
||||
DROP COLUMN IF EXISTS nationality;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddBusinessLicenseFilesToCompanyProfiles1791000000003
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = "AddBusinessLicenseFilesToCompanyProfiles1791000000003";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
ADD COLUMN IF NOT EXISTS business_license_files jsonb;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.company_profiles
|
||||
DROP COLUMN IF EXISTS business_license_files;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<Express.Multer.File>,
|
||||
): Promise<BusinessLicenseFile[]> {
|
||||
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<BusinessLicenseFile[]> {
|
||||
return this.companiesService.listProfileLicenseFiles(user.id, profileId);
|
||||
}
|
||||
|
||||
@Patch("active-mode")
|
||||
@ApiOperation({
|
||||
summary: "Switch the current user's active operational mode (importer/exporter)",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<Company> {
|
||||
@@ -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<string, any> = {};
|
||||
const attrUpdates: Record<string, any> = { ...(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<CompanyProfile> {
|
||||
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<BusinessLicenseFile[]> {
|
||||
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<BusinessLicenseFile[]> {
|
||||
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 →
|
||||
|
||||
@@ -15,7 +15,7 @@ const SEQUENCE_MAP: Record<ProfileType, string> = {
|
||||
const PREFIX_MAP: Record<ProfileType, string> = {
|
||||
[ProfileType.exporter]: "EX",
|
||||
[ProfileType.importer]: "IM",
|
||||
[ProfileType.freightForwarder]: "FFE",
|
||||
[ProfileType.freightForwarder]: "FF",
|
||||
[ProfileType.djFreightForwarder]: "FWJ",
|
||||
[ProfileType.transporter]: "TR",
|
||||
};
|
||||
|
||||
@@ -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)) ??
|
||||
[];
|
||||
|
||||
@@ -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<string, any> | 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, any> | null;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SwitchResult> | 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<File[]>([]);
|
||||
const [createError, setCreateError] = useState<string | null>(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.
|
||||
</Text>
|
||||
<TextInput
|
||||
<FileInput
|
||||
label="Business license"
|
||||
placeholder="e.g. BL-123456"
|
||||
value={businessLicense}
|
||||
onChange={(e) => setBusinessLicense(e.currentTarget.value)}
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder="Select license file(s)"
|
||||
value={licenseFiles}
|
||||
onChange={(files) => setLicenseFiles(files ?? [])}
|
||||
error={createError ?? undefined}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
|
||||
@@ -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<CompanyNationality | null>(
|
||||
savedNationality,
|
||||
);
|
||||
const [roles, setRoles] = useState<string[]>(
|
||||
existingProfiles.map((p) => p.type),
|
||||
@@ -77,8 +85,19 @@ export default function OnboardingWizardDialog({
|
||||
const [documentFiles, setDocumentFiles] = useState<
|
||||
Record<string, File | File[] | null>
|
||||
>({});
|
||||
// Newly-selected business-license files per company_profile id.
|
||||
const [licenseFiles, setLicenseFiles] = useState<Record<string, File[]>>({});
|
||||
const [startError, setStartError] = useState<string | null>(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 (
|
||||
<Modal
|
||||
@@ -188,14 +259,20 @@ export default function OnboardingWizardDialog({
|
||||
Complete your onboarding
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{phase === "role"
|
||||
? "Tell us what your company does to get started."
|
||||
: "Set up your company profile to finish."}
|
||||
{titleHint}
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
>
|
||||
{phase === "role" ? (
|
||||
{phase === "nationality" ? (
|
||||
<Stack gap="lg">
|
||||
<NationalitySelect value={nationality} onChange={setNationality} />
|
||||
<RoleContinueBar
|
||||
disabled={!nationality}
|
||||
onClick={handleNationalityContinue}
|
||||
/>
|
||||
</Stack>
|
||||
) : phase === "role" ? (
|
||||
<Stack gap="lg">
|
||||
<OnboardingRoleSelect value={roles} onChange={setRoles} />
|
||||
{startError && (
|
||||
@@ -210,35 +287,9 @@ export default function OnboardingWizardDialog({
|
||||
/>
|
||||
</Stack>
|
||||
) : isForwarder ? (
|
||||
<ForwarderForm
|
||||
documentSettingCode={documentSettingCode(companyType)}
|
||||
documentFiles={documentFiles}
|
||||
onDocumentFilesChange={setDocumentFiles}
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={finishMutation.isPending}
|
||||
onBack={handleBackToRoles}
|
||||
hideFirstStepBack
|
||||
initialStep={resumeFormStep}
|
||||
resyncOpen={opened}
|
||||
onStepChange={persistStep}
|
||||
onSaveStep={saveStep}
|
||||
/>
|
||||
<ForwarderForm {...formProps} />
|
||||
) : (
|
||||
<CompanyProfileForm
|
||||
documentSettingCode={documentSettingCode(companyType)}
|
||||
documentFiles={documentFiles}
|
||||
onDocumentFilesChange={setDocumentFiles}
|
||||
user={user}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={finishMutation.isPending}
|
||||
onBack={handleBackToRoles}
|
||||
hideFirstStepBack
|
||||
initialStep={resumeFormStep}
|
||||
resyncOpen={opened}
|
||||
onStepChange={persistStep}
|
||||
onSaveStep={saveStep}
|
||||
/>
|
||||
<CompanyProfileForm {...formProps} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, File[]>;
|
||||
onChange: (value: Record<string, File[]>) => 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 (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
Upload the business license for each of your operational profiles. You
|
||||
can attach more than one document per profile.
|
||||
</Text>
|
||||
|
||||
{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 (
|
||||
<Card key={profile.id} padding="lg" withBorder>
|
||||
<Group justify="space-between" mb="sm" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={40}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
<FileText size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} c="edr-text" fz={15}>
|
||||
{label} — Business License
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" ff="monospace">
|
||||
{profile.reference}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
{hasAny && (
|
||||
<Badge color="edr-green" variant="light">
|
||||
Provided
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{profile.existingFiles.length > 0 && (
|
||||
<Stack gap={4} mb="sm">
|
||||
{profile.existingFiles.map((f) => (
|
||||
<Group key={f.url} gap={6} wrap="nowrap">
|
||||
<Paperclip size={13} className="text-edr-muted" />
|
||||
<Anchor
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="xs"
|
||||
>
|
||||
{f.name}
|
||||
</Anchor>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FileInput
|
||||
multiple
|
||||
clearable
|
||||
accept="application/pdf,image/png,image/jpeg"
|
||||
leftSection={<Upload size={16} />}
|
||||
placeholder={
|
||||
profile.existingFiles.length > 0
|
||||
? "Upload more / replace files"
|
||||
: "Select license file(s)"
|
||||
}
|
||||
value={selected}
|
||||
onChange={(files) => setFiles(profile.id, files ?? [])}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<Result<void>> => {
|
||||
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) {
|
||||
|
||||
@@ -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<CompanyStep, (keyof FormData)[]> = {
|
||||
],
|
||||
poa: [],
|
||||
documents: [],
|
||||
confirm: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
||||
@@ -159,6 +163,40 @@ function stepPayload(step: CompanyStep, d: FormData): Partial<UpdateProfilePaylo
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the form from previously-saved profile data (splitting combined phones). */
|
||||
function toFormValues(p: ProfileResponse): FormData {
|
||||
const companyPhone = splitPhone(p.companyPhone);
|
||||
const contactPhone = splitPhone(p.contactPersonPhone);
|
||||
const gmPhone = splitPhone(p.generalManagerPhone);
|
||||
const poaPhone = splitPhone(p.poaPhone);
|
||||
// The draft placeholder TIN ("D…") shouldn't show as a real value.
|
||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||
return {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: companyPhone.number,
|
||||
companyPhoneCountryCode: companyPhone.countryCode,
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
fanNumber: p.fanNumber ?? "",
|
||||
contactPersonName: p.contactPersonName ?? "",
|
||||
contactPersonPhone: contactPhone.number,
|
||||
contactPersonPhoneCountryCode: contactPhone.countryCode,
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: gmPhone.number,
|
||||
generalManagerPhoneCountryCode: gmPhone.countryCode,
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: poaPhone.number,
|
||||
poaPhoneCountryCode: poaPhone.countryCode,
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
poaEmail: p.poaEmail ?? "",
|
||||
poaLocation: p.poaLocation ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
documentSettingCode,
|
||||
documentFiles: controlledFiles,
|
||||
@@ -172,6 +210,10 @@ export default function CompanyProfileForm({
|
||||
hideFirstStepBack,
|
||||
onStepChange,
|
||||
onSaveStep,
|
||||
rehydrate,
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -192,6 +234,13 @@ export default function CompanyProfileForm({
|
||||
onSaveStep?: (
|
||||
data: Partial<UpdateProfilePayload>,
|
||||
) => 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<string, File[]>;
|
||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<CompanyStep>(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: <User size={18} /> },
|
||||
{ key: "poa", icon: <FileText size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
{ key: "additional", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<CompanyStep, string> = {
|
||||
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" && (
|
||||
<Box
|
||||
p={16}
|
||||
className="rounded-2xl border border-edr-border bg-edr-card"
|
||||
>
|
||||
<Text fw={600} c="edr-text">
|
||||
Review your registration
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the company details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReviewRow
|
||||
label="Company name"
|
||||
value={formValues.companyName}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Company email"
|
||||
value={formValues.companyEmail}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Company phone"
|
||||
value={formValues.companyPhone}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Location"
|
||||
value={formValues.companyLocation}
|
||||
/>
|
||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
||||
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
||||
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
||||
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
||||
<ReviewRow
|
||||
label="Contact person"
|
||||
value={formValues.contactPersonName}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Contact phone"
|
||||
value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="General manager"
|
||||
value={formValues.generalManagerName}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="GM email"
|
||||
value={formValues.generalManagerEmail}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="GM phone"
|
||||
value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="PoA name"
|
||||
value={formValues.poaName || undefined}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="PoA phone"
|
||||
value={
|
||||
formValues.poaPhone && formValues.poaPhoneCountryCode
|
||||
? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="PoA email"
|
||||
value={formValues.poaEmail || undefined}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="PoA location"
|
||||
value={formValues.poaLocation || undefined}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
{step === "additional" && (
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
@@ -666,7 +660,11 @@ export default function CompanyProfileForm({
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title="Couldn't save this step"
|
||||
title={
|
||||
step === "additional"
|
||||
? "Business license required"
|
||||
: "Couldn't save this step"
|
||||
}
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
@@ -679,18 +677,14 @@ export default function CompanyProfileForm({
|
||||
onClick={prevStep}
|
||||
leftSection={<ArrowLeft size={16} />}
|
||||
>
|
||||
{step === "confirm" ? "Back to Documents" : "Back"}
|
||||
{step === "additional" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={
|
||||
step === "confirm"
|
||||
? handleSubmit((data) => onSubmit(buildPayload(data, user)))
|
||||
: nextStep
|
||||
}
|
||||
onClick={nextStep}
|
||||
disabled={
|
||||
isPending ||
|
||||
saving ||
|
||||
@@ -700,7 +694,7 @@ export default function CompanyProfileForm({
|
||||
rightSection={
|
||||
!isPending &&
|
||||
!saving &&
|
||||
step !== "confirm" &&
|
||||
step !== "additional" &&
|
||||
step !== "documents" ? (
|
||||
<ArrowRight size={16} />
|
||||
) : undefined
|
||||
@@ -708,8 +702,8 @@ export default function CompanyProfileForm({
|
||||
>
|
||||
{step === "documents"
|
||||
? "Continue"
|
||||
: step === "confirm"
|
||||
? "Submit Registration"
|
||||
: step === "additional"
|
||||
? "Finish onboarding"
|
||||
: "Save & Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -718,26 +712,3 @@ export default function CompanyProfileForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-muted"
|
||||
className="uppercase tracking-wide"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
c={value?.trim() ? "edr-text" : "edr-muted"}
|
||||
mt={4}
|
||||
>
|
||||
{value?.trim() ? value : "Not provided"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ForwarderStep, (keyof FormData)[]> = {
|
||||
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<UpdateProfilePay
|
||||
}
|
||||
}
|
||||
|
||||
/** Seed the form from previously-saved profile data (splitting combined phones). */
|
||||
function toFormValues(p: ProfileResponse): FormData {
|
||||
const companyPhone = splitPhone(p.companyPhone);
|
||||
const contactPhone = splitPhone(p.contactPersonPhone);
|
||||
const gmPhone = splitPhone(p.generalManagerPhone);
|
||||
const poaPhone = splitPhone(p.poaPhone);
|
||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||
return {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: companyPhone.number,
|
||||
companyPhoneCountryCode: companyPhone.countryCode,
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
fanNumber: p.fanNumber ?? "",
|
||||
contactPersonName: p.contactPersonName ?? "",
|
||||
contactPersonPhone: contactPhone.number,
|
||||
contactPersonPhoneCountryCode: contactPhone.countryCode,
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: gmPhone.number,
|
||||
generalManagerPhoneCountryCode: gmPhone.countryCode,
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: poaPhone.number,
|
||||
poaPhoneCountryCode: poaPhone.countryCode,
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
poaEmail: p.poaEmail ?? "",
|
||||
poaLocation: p.poaLocation ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ForwarderForm({
|
||||
documentSettingCode,
|
||||
documentFiles: controlledFiles,
|
||||
@@ -136,6 +173,10 @@ export default function ForwarderForm({
|
||||
hideFirstStepBack,
|
||||
onStepChange,
|
||||
onSaveStep,
|
||||
rehydrate,
|
||||
roleProfiles,
|
||||
licenseFiles,
|
||||
onLicenseChange,
|
||||
}: {
|
||||
documentSettingCode: string;
|
||||
documentFiles?: Record<string, File | File[] | null>;
|
||||
@@ -156,6 +197,13 @@ export default function ForwarderForm({
|
||||
onSaveStep?: (
|
||||
data: Partial<UpdateProfilePayload>,
|
||||
) => 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<string, File[]>;
|
||||
onLicenseChange?: (value: Record<string, File[]>) => void;
|
||||
}) {
|
||||
const [step, setStep] = useState<ForwarderStep>(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: <User size={18} /> },
|
||||
{ key: "poa", icon: <FileText size={18} /> },
|
||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
||||
{ key: "additional", icon: <CheckCircle2 size={18} /> },
|
||||
];
|
||||
|
||||
const STEP_LABELS: Record<ForwarderStep, string> = {
|
||||
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" && (
|
||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||
Confirm the company details below before saving.
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
||||
<ReviewRow label="Location" value={formValues.companyLocation} />
|
||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
||||
<ReviewRow label="TIN" value={formValues.tinNumber} />
|
||||
<ReviewRow label="VAT" value={formValues.vatNumber} />
|
||||
<ReviewRow label="FAN" value={formValues.fanNumber} />
|
||||
<ReviewRow label="Contact person" value={formValues.contactPersonName} />
|
||||
<ReviewRow label="Contact phone" value={`${formValues.contactPersonPhoneCountryCode}${formValues.contactPersonPhone}`} />
|
||||
<ReviewRow label="General manager" value={formValues.generalManagerName} />
|
||||
<ReviewRow label="GM email" value={formValues.generalManagerEmail} />
|
||||
<ReviewRow label="GM phone" value={`${formValues.generalManagerPhoneCountryCode}${formValues.generalManagerPhone}`} />
|
||||
<ReviewRow label="PoA name" value={formValues.poaName || undefined} />
|
||||
<ReviewRow label="PoA phone" value={formValues.poaPhone && formValues.poaPhoneCountryCode ? `${formValues.poaPhoneCountryCode}${formValues.poaPhone}` : undefined} />
|
||||
<ReviewRow label="PoA email" value={formValues.poaEmail || undefined} />
|
||||
<ReviewRow label="PoA location" value={formValues.poaLocation || undefined} />
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
{step === "additional" && (
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />} title="Couldn't save this step">
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<AlertCircle size={18} />}
|
||||
title={step === "additional" ? "Business license required" : "Couldn't save this step"}
|
||||
>
|
||||
{saveError}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -511,7 +560,7 @@ export default function ForwarderForm({
|
||||
<Group justify="space-between" pt="xs">
|
||||
{showBack ? (
|
||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
||||
{step === "confirm" ? "Back to Documents" : "Back"}
|
||||
{step === "additional" ? "Back to Documents" : "Back"}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
@@ -524,12 +573,12 @@ export default function ForwarderForm({
|
||||
)}
|
||||
<Button
|
||||
color="edr-green"
|
||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
||||
onClick={nextStep}
|
||||
disabled={isPending || saving || (step === "documents" && !hasDocuments && loadingDocuments)}
|
||||
loading={isPending || saving}
|
||||
rightSection={!isPending && !saving && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
rightSection={!isPending && !saving && step !== "additional" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
||||
>
|
||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Save & Continue"}
|
||||
{step === "documents" ? "Continue" : step === "additional" ? "Finish onboarding" : "Save & Continue"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -538,16 +587,3 @@ export default function ForwarderForm({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
||||
{value?.trim() ? value : "Not provided"}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<Globe2 size={20} />
|
||||
<Title order={3}>Where is your company registered?</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
This determines the documents we'll ask you to provide.
|
||||
</Text>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<RoleCard
|
||||
label="Ethiopian Company"
|
||||
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
|
||||
icon={<MapPin size={22} />}
|
||||
selected={value === "ethiopian"}
|
||||
onClick={() => onChange("ethiopian")}
|
||||
/>
|
||||
<RoleCard
|
||||
label="Foreign Company"
|
||||
description="Registered abroad. You'll provide a passport and investment license."
|
||||
icon={<Globe2 size={22} />}
|
||||
selected={value === "foreign"}
|
||||
onClick={() => onChange("foreign")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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<string, any> | 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<CompanyInfoResponse> => {
|
||||
const response = await client.post<ApiResponse<CompanyInfoResponse>>(
|
||||
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<LicenseFile[]> => {
|
||||
const formData = new FormData();
|
||||
for (const f of files) formData.append(code, f);
|
||||
const response = await client.post<ApiResponse<LicenseFile[]>>(
|
||||
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<LicenseFile[]> => {
|
||||
const response = await client.get<ApiResponse<LicenseFile[]>>(
|
||||
URL_CONSTANTS.COMPANIES_API.PROFILE_LICENSE(profileId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
25
apps/edr-freight-web/portal/src/utils/phone.ts
Normal file
25
apps/edr-freight-web/portal/src/utils/phone.ts
Normal file
@@ -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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user