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:
Marshal
2026-06-20 08:28:01 +00:00
parent ce8189d5fe
commit 54587a8c55
27 changed files with 978 additions and 278 deletions

View File

@@ -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": {

View File

@@ -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;
`);
}
}

View File

@@ -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;
`);
}
}

View File

@@ -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)",

View File

@@ -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: [

View File

@@ -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 →

View File

@@ -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",
};

View File

@@ -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)) ??
[];

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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,

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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),

View File

@@ -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);
},
};

View File

@@ -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;

View 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}`;
}