Merge pull request #1192 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-08 20:38:37 +03:00
committed by GitHub
31 changed files with 1395 additions and 3009 deletions

View File

@@ -421,7 +421,10 @@ export class CompaniesController {
@CurrentUser() user: CurrentIamUser,
@Body() dto: CompleteIdentityVerificationDto,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.completeIdentityVerification(user.id, dto);
return this.companiesService.completeIdentityVerification(user.id, dto, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Post("identity/gm/same-as-owner")
@@ -434,7 +437,10 @@ export class CompaniesController {
async setGmSameAsOwner(
@CurrentUser() user: CurrentIamUser,
): Promise<CompanyIdentityStateDto> {
return this.companiesService.setGmSameAsOwner(user.id);
return this.companiesService.setGmSameAsOwner(user.id, {
email: user.email,
phoneNumber: user.phoneNumber,
});
}
@Delete("identity/gm")

View File

@@ -322,41 +322,15 @@ describe("Fayda identity verification binds a person to the company", () => {
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
// shape: a sub, no contact details.
it("keeps company contact details a Fayda verification never supplied", async () => {
const { service, deps } = makeService({
const { deps } = makeService({
attributes: { ...OWNER_VERIFIED },
});
await expect(
service.updateProfile("user-1", {
companyEmail: "account@example.com",
companyPhone: "+251911777777",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("account@example.com");
expect(patch.phone).toBe("+251911777777");
});
it("overwrites company contact details the verification did supply", async () => {
const { service, deps } = makeService({
attributes: {
...OWNER_VERIFIED,
ownerEmail: "abebe@example.com",
ownerPhone: "+251911000000",
},
});
await expect(
service.updateProfile("user-1", {
companyEmail: "someone-else@example.com",
companyPhone: "+251911999999",
} as never),
).resolves.toBeDefined();
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
expect(patch.email).toBe("abebe@example.com");
expect(patch.phone).toBe("+251911000000");
});
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
// onboarding, hidden by the portal's link card and unwritable at once.

View File

@@ -236,8 +236,6 @@ export class CompaniesService {
label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
},
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{
@@ -314,8 +312,6 @@ export class CompaniesService {
fanNumber: dto.fanNumber ?? null,
country: dto.companyLocation ?? "Ethiopia",
address: dto.companyAddress ?? null,
phone: normalizeE164(dto.companyPhone) ?? null,
email: dto.companyEmail ?? null,
attributes: dto.attributes ?? null,
});
@@ -492,7 +488,8 @@ export class CompaniesService {
async findCompanyById(id: string): Promise<Company> {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
company.companyProfiles =
await this.companyProfilesRepo.findByCompanyId(id);
// External profiles carry the onboarding flag the backoffice gates
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
company.profiles = await this.profilesRepo.findByCompanyId(id);
@@ -515,9 +512,7 @@ export class CompaniesService {
);
}
if (profile.status !== ProfileStatus.Active) {
throw new BadRequestException(
"Selected company profile is not active",
);
throw new BadRequestException("Selected company profile is not active");
}
return profile;
}
@@ -760,11 +755,6 @@ export class CompaniesService {
};
const keys: string[] = [];
if (attrs.ownerFaydaSub) {
// The Company-column mirrors of the owner's verified contact details.
if (held("ownerEmail")) keys.push("companyEmail");
if (held("ownerPhone")) keys.push("companyPhone");
}
for (const subject of IDENTITY_SUBJECTS) {
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
@@ -789,9 +779,6 @@ export class CompaniesService {
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 = normalizeE164(dto.companyPhone);
if (dto.companyLocation !== undefined)
companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined)
@@ -809,7 +796,9 @@ export class CompaniesService {
if (dto.contactPersonPhone !== undefined)
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
if (dto.contactVerifiedPhone !== undefined)
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
attrUpdates.contactVerifiedPhone = normalizeE164(
dto.contactVerifiedPhone,
);
if (dto.generalManagerName !== undefined)
attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined)
@@ -820,7 +809,8 @@ export class CompaniesService {
if (dto.poaPhone !== undefined)
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaLocation !== undefined)
attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
if (dto.licenceNumber !== undefined)
@@ -857,12 +847,6 @@ export class CompaniesService {
Object.assign(attrUpdates, dto.faydaIdentity);
}
// companyEmail/companyPhone are the Company-column mirrors of the owner's
// verified contact details (the portal derives and submits them, it never
// lets the customer type them once verified) — lock them the same way
// ownerEmail/ownerPhone themselves are locked below, once there is a
// verified owner to lock them to.
//
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
// phone claims are optional, so a verification can prove the person while
// supplying neither (see completeIdentityVerification's conditional
@@ -872,9 +856,8 @@ export class CompaniesService {
// forever, and re-verifying could never clear it because Fayda still has
// nothing to return.
if (attrUpdates.ownerFaydaSub) {
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
companyUpdates.email = attrUpdates.ownerEmail;
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
if (attrUpdates.ownerPhone)
companyUpdates.phone = normalizeE164(String(attrUpdates.ownerPhone));
}
@@ -1086,9 +1069,7 @@ export class CompaniesService {
}
/** List a company's change requests, newest first (backoffice review). */
async listChangeRequests(
companyId: string,
): Promise<CompanyChangeRequest[]> {
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
await this.findCompanyById(companyId);
return this.changeRequestRepo.findByCompanyId(companyId);
}
@@ -1150,8 +1131,7 @@ export class CompaniesService {
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (!request) throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
@@ -1164,7 +1144,10 @@ export class CompaniesService {
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
await this.assertTinAvailable(company, snapshot.tin);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(company, snapshot);
const companyUpdates = this.mapProfileDtoToCompanyUpdates(
company,
snapshot,
);
await this.companiesRepo.update(company.id, companyUpdates);
await this.applyLicenseChanges(request);
await this.applyDocumentChanges(request);
@@ -1294,7 +1277,12 @@ export class CompaniesService {
);
}
if (documentChanges.length > 0) {
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
await this.recordCompanyRevision(
company,
{},
submittedBy,
documentChanges,
);
}
return uploaded;
}
@@ -1414,7 +1402,11 @@ export class CompaniesService {
status: ChangeRequestStatus.Pending,
});
if (company) {
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
this.companyNotifier.changeRequestSubmitted(
company,
existing.id,
false,
);
}
} else {
const history = await this.changeRequestRepo.findByCompanyId(companyId);
@@ -1446,8 +1438,7 @@ export class CompaniesService {
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (!request) throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
@@ -1486,8 +1477,7 @@ export class CompaniesService {
reviewerId?: string,
): Promise<CompanyChangeRequest> {
const request = await this.changeRequestRepo.findById(id);
if (!request)
throw new NotFoundException(`Change request ${id} not found`);
if (!request) throw new NotFoundException(`Change request ${id} not found`);
if (request.status !== ChangeRequestStatus.Pending) {
throw new BadRequestException(
`Change request ${id} is already ${request.status}`,
@@ -1569,10 +1559,7 @@ export class CompaniesService {
const reactivating =
status === ProfileStatus.Active &&
existing.status === ProfileStatus.Suspended;
if (
(status === ProfileStatus.Suspended || reactivating) &&
!note?.trim()
) {
if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
throw new BadRequestException(
status === ProfileStatus.Suspended
? "A message explaining the suspension is required — the customer will see it."
@@ -1594,7 +1581,9 @@ export class CompaniesService {
existing.status === ProfileStatus.Pending ||
existing.status === ProfileStatus.Rejected;
if (awaitingReview) {
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
const owners = await this.profilesRepo.findByCompanyId(
existing.companyId,
);
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
throw new BadRequestException(
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
@@ -1656,7 +1645,13 @@ export class CompaniesService {
);
}
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
return this.applyProfileStatus(
manager,
existing,
status,
note,
reviewerId,
);
});
}
@@ -1992,7 +1987,9 @@ export class CompaniesService {
.map((f) => ({ key: f.key, label: f.label }));
// 2. Nationality-based company documents + which are already uploaded.
const documentSettingCode = this.documentSettingCodeFor(company.nationality);
const documentSettingCode = this.documentSettingCodeFor(
company.nationality,
);
const [setting, uploadedFiles] = await Promise.all([
this.fileUploadSettingsService
.getByCode(documentSettingCode)
@@ -2074,7 +2071,9 @@ export class CompaniesService {
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
: []),
...(flaggedDelegation
? [`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`]
? [
`Re-upload your ${POA_DELEGATION_LABEL} — EDR asked for a correction`,
]
: []),
...(identity.faydaRequired && !identity.owner.verified
? ["Verify the company owner's identity with Fayda"]
@@ -2137,7 +2136,10 @@ export class CompaniesService {
return new OnboardingRequirementsResponseDto({
documentSettingCode,
nationality: company.nationality ?? CompanyNationality.Ethiopian,
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
companyInfo: {
complete: missingInfo.length === 0,
missingFields: missingInfo,
},
documents,
licenseProfiles,
poa: {
@@ -2188,7 +2190,10 @@ export class CompaniesService {
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
for (const cp of profiles) {
if (cp.status !== ProfileStatus.Pending) {
await this.companyProfilesRepo.updateStatus(cp.id, ProfileStatus.Pending);
await this.companyProfilesRepo.updateStatus(
cp.id,
ProfileStatus.Pending,
);
}
}
@@ -2248,8 +2253,7 @@ export class CompaniesService {
switch (profile.status) {
case ProfileStatus.Suspended:
throw new ForbiddenException(
`Your ${role} role is suspended${
profile.reviewNote ? `${profile.reviewNote}` : ""
`Your ${role} role is suspended${profile.reviewNote ? `${profile.reviewNote}` : ""
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
);
case ProfileStatus.Blacklisted:
@@ -2258,8 +2262,7 @@ export class CompaniesService {
);
case ProfileStatus.Rejected:
throw new ForbiddenException(
`Your ${role} role was rejected${
profile.reviewNote ? `${profile.reviewNote}` : ""
`Your ${role} role was rejected${profile.reviewNote ? `${profile.reviewNote}` : ""
}. Amend and resubmit it from your settings page.`,
);
default:
@@ -2518,9 +2521,7 @@ export class CompaniesService {
LICENSE_RESOURCE,
);
return records
.filter(
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
)
.filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
.map((r) => ({
id: r.id,
name: r.name,
@@ -2748,6 +2749,12 @@ export class CompaniesService {
async completeIdentityVerification(
userId: string,
dto: CompleteIdentityVerificationDto,
/**
* The signed-in account, used as the owner's fallback contact details.
* Optional so the callers that only have a user id keep compiling — they
* simply get no fallback.
*/
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const prefix = IDENTITY_PREFIX[dto.subject];
@@ -2769,7 +2776,8 @@ export class CompaniesService {
// of this check entirely.
if (dto.subject === "owner" || dto.subject === "poa") {
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa";
const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
const otherSub =
company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`];
if (otherSub && otherSub === result.sub) {
throw new BadRequestException(
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
@@ -2778,6 +2786,22 @@ export class CompaniesService {
}
const now = new Date().toISOString();
// Fayda's email and phone claims are optional and routinely come back empty.
// For the owner that leaves the company with no contact details at all: the
// step renders no input for them (they are the verification's output), and
// "same as owner" then copies those blanks onto `generalManagerEmail` /
// `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit —
// an unfixable dead end. The account doing the onboarding is the one contact
// we always have, and it is already OTP-proven, so it stands in.
//
// Owner only: the PoA and the GM are other people, and the registering
// account's address is not theirs to wear.
const isOwner = dto.subject === "owner";
const email = result.email || (isOwner ? account?.email : undefined);
const phone =
result.phoneNumber || (isOwner ? account?.phoneNumber : undefined);
const identity: VerifiedIdentityAttributes = {
[`${prefix}FaydaSub`]: result.sub,
[`${prefix}FaydaVerifiedAt`]: now,
@@ -2785,14 +2809,12 @@ export class CompaniesService {
[`${prefix}Gender`]: result.gender ?? null,
// The verified payload owns the person's details from here on.
...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}),
...(result.email ? { [`${prefix}Email`]: result.email } : {}),
...(email ? { [`${prefix}Email`]: email } : {}),
// Fayda returns whatever the national registry holds, which is routinely a
// local number ("0911223344"). Every typed phone in this service is stored
// E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here
// becomes a value the portal reads back and cannot resubmit.
...(result.phoneNumber
? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) }
: {}),
...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}),
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
};
@@ -2842,7 +2864,13 @@ export class CompaniesService {
* proven identity to copy, only typed text that would arrive wearing a
* verified badge.
*/
async setGmSameAsOwner(userId: string): Promise<CompanyIdentityStateDto> {
async setGmSameAsOwner(
userId: string,
/** Same fallback as {@link completeIdentityVerification}, for owners
* verified before that fallback existed — their stored contact details are
* blank, and copying blanks here would block the submit. */
account?: { email?: string; phoneNumber?: string },
): Promise<CompanyIdentityStateDto> {
const { company } = await this.getCompanyInfoByUserId(userId);
const attrs = company.attributes ?? {};
const ownerSub = attrs.ownerFaydaSub as string | undefined;
@@ -2852,20 +2880,23 @@ export class CompaniesService {
);
}
const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null;
const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null;
const copied: Record<string, unknown> = {
gmSameAsOwner: true,
gmFaydaSub: ownerSub,
gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(),
gmName: attrs.ownerName ?? null,
gmEmail: attrs.ownerEmail ?? null,
gmPhone: attrs.ownerPhone ?? null,
gmEmail: ownerEmail,
gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
gmAddress: attrs.ownerAddress ?? null,
gmBirthdate: attrs.ownerBirthdate ?? null,
gmGender: attrs.ownerGender ?? null,
// Kept in step for the notifiers, same as a GM verification does.
generalManagerName: attrs.ownerName ?? null,
generalManagerEmail: attrs.ownerEmail ?? null,
generalManagerPhone: attrs.ownerPhone ?? null,
generalManagerEmail: ownerEmail,
generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null,
};
const updated = await this.companiesRepo.update(company.id, {
@@ -2976,8 +3007,8 @@ export class CompaniesService {
const snapshot = {
...(existing?.snapshot ?? {}),
faydaIdentity: {
...(((existing?.snapshot ?? {}) as Record<string, any>)
.faydaIdentity ?? {}),
...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
{}),
...identity,
},
};
@@ -3389,7 +3420,10 @@ export class CompaniesService {
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
return this.etradeService.extractRegistrationData(
businessInfo,
companyInfo,
);
}
async fetchETradeData(tin: string, excludeCompanyId?: string) {
@@ -3421,7 +3455,9 @@ export class CompaniesService {
const tin = dto.tin ?? company.tin;
const registration = await this.resolveEtradeRegistration(tin);
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
const fresh: Partial<
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
> = {
companyName: registration.companyName,
licenceNumber: registration.licenceNumber,
statusDescription: registration.statusDescription,

View File

@@ -1,9 +1,18 @@
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';
import { CompanyType } from '../entities/company.entity';
import { ProfileType } from '../entities/company-profile.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsTin } from '../../../common/validators/is-tin.validator';
import {
IsString,
IsNotEmpty,
IsOptional,
MaxLength,
IsBoolean,
IsEnum,
IsArray,
ValidateNested,
ArrayMinSize,
} from "class-validator";
import { Type } from "class-transformer";
import { CompanyType } from "../entities/company.entity";
import { ProfileType } from "../entities/company-profile.entity";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class CompanyProfileInputDto {
@IsEnum(ProfileType)
@@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto {
@MaxLength(200)
companyName!: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto {
@IsOptional()
@IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' })
@IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string;
@IsOptional()

View File

@@ -2,21 +2,19 @@ import {
buildCompanyIdentityState,
CompanyIdentityStateDto,
} from "./complete-identity-verification.dto";
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
import { Company } from "../entities/company.entity";
import { ExternalProfile } from "../entities/external-profile.entity";
import {
ChangeRequestStatus,
CompanyChangeRequest,
} from '../entities/company-change-request.entity';
import { ResponseCompanyProfileDto } from './response-company.dto';
} from "../entities/company-change-request.entity";
import { ResponseCompanyProfileDto } from "./response-company.dto";
export class ProfileResponseDto {
companyId: string;
companyName: string;
companyType: string;
nationality: string | null;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -89,8 +87,6 @@ export class ProfileResponseDto {
this.companyProfiles =
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
[];
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;
this.companyAddress = company.address ?? null;
this.tinNumber = company.tin;

View File

@@ -6,11 +6,11 @@ import {
IsEnum,
IsIn,
Matches,
} from 'class-validator';
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
import { IsTin } from '../../../common/validators/is-tin.validator';
} from "class-validator";
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
import { CompanyNationality } from "../entities/company.entity";
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
import { IsTin } from "../../../common/validators/is-tin.validator";
export class UpdateProfileDto {
@IsOptional()
@@ -22,17 +22,6 @@ export class UpdateProfileDto {
@MaxLength(200)
companyName?: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
@IsValidPhone()
companyPhone?: string;
@IsOptional()
@IsString()
@MaxLength(32)
@@ -44,7 +33,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@IsTin({ message: 'TIN must be exactly 10 digits' })
@IsTin({ message: "TIN must be exactly 10 digits" })
tin?: string;
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
@@ -53,7 +42,7 @@ export class UpdateProfileDto {
// column may hold.
@IsOptional()
@IsString()
@Matches(/^\d{10}$/, { message: 'VAT number must be exactly 10 digits' })
@Matches(/^\d{10}$/, { message: "VAT number must be exactly 10 digits" })
vatNumber?: string;
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the

View File

@@ -582,9 +582,7 @@ const App = () => {
<Route
path="routes"
element={
<RequirePermission
permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.routes.view}>
<RoutesPage />
</RequirePermission>
}
@@ -592,12 +590,7 @@ const App = () => {
<Route
path="locomotives"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
]}
>
<RequirePermission permission={FREIGHT_PERMS.locomotives.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -605,9 +598,7 @@ const App = () => {
<Route
path="trains"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.trains.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -615,9 +606,7 @@ const App = () => {
<Route
path="trains/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.trains.view}>
<TrainDetailPage />
</RequirePermission>
}
@@ -625,9 +614,7 @@ const App = () => {
<Route
path="train-builder"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.trains.view}>
<TrainBuilderListPage />
</RequirePermission>
}
@@ -635,9 +622,7 @@ const App = () => {
<Route
path="train-builder/:id"
element={
<RequirePermission
permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.trains.view}>
<TrainBuilderDetailPage />
</RequirePermission>
}
@@ -645,9 +630,7 @@ const App = () => {
<Route
path="wagons"
element={
<RequirePermission
permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.wagons.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -668,12 +651,7 @@ const App = () => {
<Route
path="containers"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.containers.view,
FREIGHT_PERMS.fleet.view,
]}
>
<RequirePermission permission={FREIGHT_PERMS.containers.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -681,12 +659,7 @@ const App = () => {
<Route
path="cargoes"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.cargoes.view,
FREIGHT_PERMS.fleet.view,
]}
>
<RequirePermission permission={FREIGHT_PERMS.cargoes.view}>
<FleetResourcePage />
</RequirePermission>
}
@@ -774,9 +747,7 @@ const App = () => {
<Route
path="compliance"
element={
<RequirePermission
permission={[FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.compliance.view}>
<CompliancePage />
</RequirePermission>
}
@@ -800,9 +771,7 @@ const App = () => {
<Route
path="procurement"
element={
<RequirePermission
permission={[FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view]}
>
<RequirePermission permission={FREIGHT_PERMS.procurement.view}>
<ProcurementPage />
</RequirePermission>
}
@@ -825,9 +794,7 @@ const App = () => {
<Route
path="file-settings"
element={
<RequirePermission
permission={[FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin]}
>
<RequirePermission permission={FREIGHT_PERMS.settings.fileUpload.view}>
<FileUploadSettingsPage />
</RequirePermission>
}
@@ -835,9 +802,7 @@ const App = () => {
<Route
path="dropdown-settings"
element={
<RequirePermission
permission={[FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin]}
>
<RequirePermission permission={FREIGHT_PERMS.settings.dropdown.view}>
<DropdownSettingsPage />
</RequirePermission>
}
@@ -854,10 +819,7 @@ const App = () => {
path="contract-templates"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
permission={FREIGHT_PERMS.settings.contractTemplates.view}
>
<ContractTemplatesPage />
</RequirePermission>
@@ -867,10 +829,7 @@ const App = () => {
path="contract-templates/:code"
element={
<RequirePermission
permission={[
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
]}
permission={FREIGHT_PERMS.settings.contractTemplates.view}
>
<ContractTemplateEditorPage />
</RequirePermission>
@@ -883,7 +842,6 @@ const App = () => {
permission={[
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
]}
>
<PortalContentPage />
@@ -908,9 +866,7 @@ const App = () => {
<Route
path="configuration/trade-access"
element={
<RequirePermission
permission={[FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin]}
>
<RequirePermission permission={FREIGHT_PERMS.tradeAccess.view}>
<TradeAccessPage />
</RequirePermission>
}
@@ -918,9 +874,7 @@ const App = () => {
<Route
path="configuration/exchange-rate"
element={
<RequirePermission
permission={[FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin]}
>
<RequirePermission permission={FREIGHT_PERMS.settings.exchangeRate.view}>
<div className="p-4">
<ExchangeRateSettingsCard />
</div>

View File

@@ -32,8 +32,6 @@ import { formatDate, humanize } from "./format";
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
export const FIELD_LABELS: Record<string, string> = {
companyName: "Company name",
companyEmail: "Company email",
companyPhone: "Company phone",
companyLocation: "Location",
companyAddress: "Address",
tin: "TIN",
@@ -73,8 +71,6 @@ export function currentValue(company: Company, key: string): string {
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
const map: Record<string, unknown> = {
companyName: c.name,
companyEmail: c.email,
companyPhone: c.phone,
companyLocation: c.country,
companyAddress: c.address,
tin: c.tin,
@@ -118,7 +114,8 @@ function FaydaIdentityDiff({
if (!subject) return null;
const current =
subject === "owner" ? company.identity?.owner : company.identity?.poa;
const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined;
const read = (key: string) =>
snapshot[`${subject}${key}`] as string | undefined;
const verifiedAt = read("FaydaVerifiedAt");
const fields: { label: string; from?: string | null; to?: string }[] = [
{ label: "Name", from: current?.name, to: read("Name") },
@@ -131,7 +128,9 @@ function FaydaIdentityDiff({
<Stack gap={8}>
<Group gap={8}>
<Text size="sm" fw={600} c="edr-text">
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
{subject === "owner"
? "Owner re-verification"
: "PoA re-verification"}
</Text>
{verifiedAt && (
<Text size="xs" c="dimmed">
@@ -283,8 +282,8 @@ export function ChangeRequestReview({ company }: { company: Company }) {
icon={<AlertTriangle size={16} />}
>
Changes were requested on an earlier round of this same
submission: <strong>{pending.note}</strong> check whether
this resubmission actually addresses it before approving.
submission: <strong>{pending.note}</strong> check whether this
resubmission actually addresses it before approving.
</Alert>
)}
@@ -312,7 +311,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
) : null}
{faydaIdentitySnapshot && (
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
<FaydaIdentityDiff
company={company}
snapshot={faydaIdentitySnapshot}
/>
)}
{documentChanges.length > 0 && (
@@ -373,9 +375,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
type="button"
size="sm"
onClick={() =>
void fetchViewableFile(fileId, `Document ${i + 1}`).then(
view,
)
void fetchViewableFile(
fileId,
`Document ${i + 1}`,
).then(view)
}
>
Document {i + 1}
@@ -446,7 +449,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
variant="light"
color="yellow"
onClick={() => {
setActionTarget({ id: pending.id, kind: "request-changes" });
setActionTarget({
id: pending.id,
kind: "request-changes",
});
setNote("");
}}
>
@@ -525,7 +531,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
}
/** Compact "N changes pending" pill for the customer list/detail header. */
export function ChangeRequestPendingBadge({ companyId }: { companyId: string }) {
export function ChangeRequestPendingBadge({
companyId,
}: {
companyId: string;
}) {
const query = useQuery(
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
);

View File

@@ -203,22 +203,19 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
permission: FREIGHT_PERMS.routes.view,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
permission: [
FREIGHT_PERMS.locomotives.view,
FREIGHT_PERMS.fleet.view,
],
permission: FREIGHT_PERMS.locomotives.view,
},
{
label: "Train Builder",
href: "/dashboard/train-builder",
icon: <Hammer />,
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
permission: FREIGHT_PERMS.trains.view,
},
// {
@@ -230,7 +227,7 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Wagons",
href: "/dashboard/wagons",
icon: <Truck />,
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
permission: FREIGHT_PERMS.wagons.view,
},
{
label: "Wagon Transfers",
@@ -287,21 +284,23 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "Compliance & Alerts",
href: "/dashboard/compliance",
icon: <ShieldCheck />,
permission: [FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view],
permission: FREIGHT_PERMS.compliance.view,
},
{
label: "Incidents",
href: "/dashboard/incidents",
icon: <FileText />,
// No dedicated backend key exists for incidents yet — stuck on the
// blanket fleet:view fallback until one is added.
// No dedicated backend key exists for incidents yet. Not part of
// the fleet.view/admin fallback cleanup — removing fleet.view
// here with nothing to replace it would lock the page to
// super-admin only, so it stays as the sole (if coarse) gate.
permission: FREIGHT_PERMS.fleet.view,
},
{
label: "Procurement",
href: "/dashboard/procurement",
icon: <Package />,
permission: [FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view],
permission: FREIGHT_PERMS.procurement.view,
},
{
label: "Financial Reports",
@@ -486,23 +485,20 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
label: "File settings",
href: "/dashboard/file-settings",
icon: <Paperclip />,
permission: [FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin],
permission: FREIGHT_PERMS.settings.fileUpload.view,
},
{
label: "Dropdown settings",
href: "/dashboard/dropdown-settings",
icon: <Settings />,
permission: [FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin],
permission: FREIGHT_PERMS.settings.dropdown.view,
},
{
label: "Contract templates",
href: "/dashboard/contract-templates",
icon: <ScrollText />,
// `view` opens the page; `read` alone is API-only and shows no menu.
permission: [
FREIGHT_PERMS.settings.contractTemplates.view,
FREIGHT_PERMS.admin,
],
permission: FREIGHT_PERMS.settings.contractTemplates.view,
},
{
label: "Portal content",
@@ -511,7 +507,6 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
permission: [
FREIGHT_PERMS.settings.supportContent.view,
FREIGHT_PERMS.settings.supportContent.manage,
FREIGHT_PERMS.admin,
],
},
{
@@ -534,12 +529,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
{
label: "Trade access",
href: "/dashboard/configuration/trade-access",
permission: [FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin],
permission: FREIGHT_PERMS.tradeAccess.view,
},
{
label: "Exchange rate",
href: "/dashboard/configuration/exchange-rate",
permission: [FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin],
permission: FREIGHT_PERMS.settings.exchangeRate.view,
},
],
},

View File

@@ -659,19 +659,13 @@ export type FleetCrudResource =
| "vehicles"
| "drivers";
/**
* Per-resource fleet CRUD check. The legacy coarse fleet:manage key still
* grants every action (mirrors the API's one-of guard fallback).
*/
/** Per-resource fleet CRUD check — each resource needs its own grant. */
export function canFleetAction(
user: AuthUser | null | undefined,
resource: FleetCrudResource,
action: "create" | "update" | "delete",
): boolean {
return (
hasPermission(user, FREIGHT_PERMS[resource][action]) ||
hasPermission(user, FREIGHT_PERMS.fleet.manage)
);
return hasPermission(user, FREIGHT_PERMS[resource][action]);
}
/**

View File

@@ -87,11 +87,7 @@ const TAB_ITEMS: Array<{
icon: TrainFront,
kpiKey: "operations",
metricKey: "wagonsAvailable",
permission: [
FREIGHT_PERMS.fleet.view,
FREIGHT_PERMS.wagons.view,
FREIGHT_PERMS.trainScheduling.view,
],
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
},
{
value: "customers",

View File

@@ -85,9 +85,7 @@ export default function TrainBuilderDetailPage() {
const { user } = useAuth();
const canUpdate = canFleetAction(user, "trains", "update");
const canDelete = canFleetAction(user, "trains", "delete");
const canAssign =
hasPermission(user, FREIGHT_PERMS.trains.assignWagons) ||
hasPermission(user, FREIGHT_PERMS.fleet.manage);
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),

View File

@@ -72,12 +72,7 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
? (identity.faydaRequired && !identity.owner.verified) ||
(identity.passportRequired && !identity.owner.passportNumber)
: false;
return (
!profile.companyEmail ||
!profile.companyPhone ||
!profile.companyAddress ||
identityIncomplete
);
return !profile.companyAddress || identityIncomplete;
}
case "contact":
return !profile.contactPersonName || !profile.contactPersonPhone;
@@ -399,7 +394,11 @@ export default function SettingsPage() {
itself, not the panel. */}
<Tabs.Panel value="company">
<Fieldset disabled={locked} variant="unstyled" p={0}>
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
<TabCompanyProfile
mode="edit"
profile={profile}
user={user ?? undefined}
/>
</Fieldset>
<OperationalServicesCard profile={profile} />
</Tabs.Panel>

View File

@@ -1,14 +1,4 @@
import {
Alert,
Button,
Divider,
Group,
Loader,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Alert, Button, Group, Stack } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
@@ -19,16 +9,11 @@ import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyRegistrationData } from "@edr/types";
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common";
import { toEthiopianE164 } from "@/components/PhoneField";
import { getMinFiles } from "@/types/fileUploadSettings";
import { api } from "@/services/api";
import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
import type { ETradeStatus } from "@/components/onboarding/ETradeInfo";
import {
buildOnboardingSchema,
type CompanyStep,
@@ -45,13 +30,13 @@ import {
stepPayload,
toFormValues,
} from "./companyProfileForm/helpers";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import { verifaydaService } from "@/services/verifayda.service";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
import StepSection from "./companyProfileForm/StepSection";
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
import PersonnelStep from "./companyProfileForm/steps/PersonnelStep";
import ContactStep from "./companyProfileForm/steps/ContactStep";
import PoaStep from "./companyProfileForm/steps/PoaStep";
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
export default function CompanyProfileForm({
documentSettingCode,
@@ -199,15 +184,7 @@ export default function CompanyProfileForm({
// forms (plus a mandatory owner passport number).
const verifiedIdentity = identity?.faydaRequired === true;
const {
register,
control,
trigger,
watch,
setValue,
getValues,
formState: { errors, dirtyFields },
} = useForm<FormData>({
const form = useForm<FormData>({
resolver: zodResolver(
buildOnboardingSchema(identity?.passportRequired === true),
),
@@ -218,8 +195,6 @@ export default function CompanyProfileForm({
resetOptions: { keepDirtyValues: true, keepErrors: true },
defaultValues: {
companyName: "",
companyEmail: "",
companyPhone: "",
companyAddress: "",
etradePhone: "",
tinNumber: "",
@@ -253,6 +228,16 @@ export default function CompanyProfileForm({
values: rehydrate ? toFormValues(rehydrate) : undefined,
});
// The step components take the whole `form`; the orchestration below drives
// validation and persistence, so it only pulls out what it actually calls.
const {
trigger,
watch,
setValue,
getValues,
formState: { dirtyFields },
} = form;
// The contact person's email still just seeds from the account and stays editable.
useEffect(() => {
if (!user?.email) return;
@@ -348,38 +333,6 @@ export default function CompanyProfileForm({
setEtradeOwner(null);
};
// companyEmail/companyPhone are derived, not typed — the Fayda-verified owner
// is the highest-trust source (that's the whole point of verifying), eTrade's
// registered number and the account email/phone are the fallbacks used
// before verification happens.
//
// `firstValid*`, not `??`: these sources are optional AND unreliable. Fayda's
// email/phone claims can come back empty, and eTrade's registered phone is
// free text that arrives as things like "09 " (→ "+2519"). `??` stops
// at the first non-null, so a junk value became a field with no input and a
// 400 from the API on a value the customer never typed. Skip anything that
// isn't usable and fall through.
//
// When every source really is unusable the fields become editable below
// rather than blocking — the API requires a company email and phone at
// submit (REQUIRED_COMPANY_INFO), so leaving no way to supply them is a dead
// end.
const derivedEmail = firstValidEmail(identity?.owner.email, user.email);
const derivedPhone = firstValidPhone(
identity?.owner.phone,
etradeOwner?.phone,
user.phoneNumber,
);
useEffect(() => {
if (derivedEmail) setValue("companyEmail", derivedEmail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [derivedEmail, rehydrate]);
useEffect(() => {
if (derivedPhone) setValue("companyPhone", derivedPhone);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [derivedPhone, rehydrate]);
// "Same as …" links. A checked card prefills the target step's fields from the
// source step and disables them (kept mirrored while linked); unchecking clears
// them and re-enables editing.
@@ -400,14 +353,6 @@ export default function CompanyProfileForm({
}, [identity]);
const [contactSameAsGm, setContactSameAsGm] = useState(false);
// General Manager source. The company step's email/phone are seeded from
// eTrade (and the account email) but stay editable, so the link reads the
// CURRENT form values rather than the frozen eTrade snapshot — an edit on the
// company step propagates here, the same way "Same as General Manager" tracks
// the general manager's live values. eTrade's owner name has no editable
// field of its own, so it falls back to the registering user's account name.
const companyEmail = watch("companyEmail");
const companyPhone = watch("companyPhone");
// A Fayda-verified owner outranks eTrade's registered owner — it's the
// higher-trust source, and the whole point of proving identity is to stop
// trusting typed/looked-up data for this.
@@ -417,17 +362,12 @@ export default function CompanyProfileForm({
user.name?.en,
);
const gmSourceEmail = firstValidEmail(
identity?.owner.email,
companyEmail,
user.email,
);
const gmSourceEmail = firstValidEmail(identity?.owner.email, user.email);
// Same reason as `derivedPhone`: this value is written into
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
// unusable eTrade number here 400s the personnel step instead.
const gmSourcePhone = firstValidPhone(
identity?.owner.phone,
companyPhone,
etradeOwner?.phone,
user.phoneNumber,
);
@@ -480,7 +420,9 @@ export default function CompanyProfileForm({
setSaveError(
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message ??
(err instanceof Error ? err.message : "Could not update the general manager"),
(err instanceof Error
? err.message
: "Could not update the general manager"),
);
} finally {
setGmLinkPending(false);
@@ -734,11 +676,7 @@ export default function CompanyProfileForm({
*/
const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => {
if (s !== "company" || !identity) return stepFields[s];
return [
...stepFields.company,
...(derivedEmail ? [] : (["companyEmail"] as const)),
...(derivedPhone ? [] : (["companyPhone"] as const)),
];
return [...stepFields.company];
};
/** Validate + persist the current step, returning whether we may advance. */
@@ -753,9 +691,7 @@ export default function CompanyProfileForm({
if (!onSaveStep) return true;
setSaving(true);
try {
const res = await onSaveStep(
stepPayload(step, getValues(), dirtyFields),
);
const res = await onSaveStep(stepPayload(step, getValues(), dirtyFields));
if (!res.ok) {
setSaveError(res.error);
return false;
@@ -828,8 +764,14 @@ export default function CompanyProfileForm({
// saveCurrentStep()'s trigger() below catches that; checking the stale
// server-side identity.owner.passportNumber here would block a value the
// user just typed but hasn't saved yet.
if (step === "company" && identity?.faydaRequired && !identity.owner.verified) {
setSaveError("Verify the company owner's identity with Fayda before continuing.");
if (
step === "company" &&
identity?.faydaRequired &&
!identity.owner.verified
) {
setSaveError(
"Verify the company owner's identity with Fayda before continuing.",
);
return;
}
// The GM is established through Fayda now, so the step gates on the
@@ -877,7 +819,9 @@ export default function CompanyProfileForm({
// which is unreachable while this save keeps failing.
if (step === "poa" && delegationRequired && onUploadDocuments) {
const pending = documentFiles[POA_DELEGATION_FILE_KEY];
const hasPending = Array.isArray(pending) ? pending.length > 0 : pending != null;
const hasPending = Array.isArray(pending)
? pending.length > 0
: pending != null;
if (hasPending) {
setSaving(true);
try {
@@ -909,363 +853,69 @@ export default function CompanyProfileForm({
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={
watch("vatNumber")?.length === 10 && !errors.vatNumber
? "done"
: "todo"
}
>
<TextInput
aria-label="VAT Number"
placeholder="0012345678"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
<CompanyInfoStep
form={form}
identity={identity}
verifiedIdentity={verifiedIdentity}
tinStatus={tinStatus}
tinVerified={tinVerified}
hasRegistrationDetails={hasRegistrationDetails}
onETradeDataLoaded={handleETradeDataLoaded}
onETradeStatusChange={setTinStatus}
onETradeReset={handleETradeReset}
/>
</StepSection>
<StepSection
index={2}
title="Owner identity"
subtitle={
!identity?.owner.verified && !verifiedIdentity
? "Provide the company owner's passport number."
: undefined
}
status={
verifiedIdentity
? identity?.owner.verified
? "done"
: identity?.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity?.passportRequired
? "blocked"
: "todo"
}
>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Owner"
state={identity.owner}
required={identity.faydaRequired}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
{/* Normally derived from the verified owner (falling back
to eTrade and the account), and shown read-only. Fayda's
email and phone claims are optional though, so when
every source comes up empty these become typeable —
the API requires both at submit, and having no input
for them is otherwise an unrecoverable dead end. */}
<SimpleGrid cols={2} spacing="md">
{derivedEmail ? (
<ReadOnlyField
label="Company email"
value={derivedEmail}
/>
) : (
<TextInput
label="Company Email"
type="email"
description="We couldn't find one on your verified identity or account — please enter it."
placeholder="company@example.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
)}
{derivedPhone ? (
<ReadOnlyField
label="Company phone"
value={derivedPhone}
/>
) : (
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
)}
</SimpleGrid>
</>
)}
</StepSection>
<StepSection
index={3}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
status={
tinVerified
? "done"
: tinStatus === "taken"
? "blocked"
: "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
onReset={handleETradeReset}
alreadyVerified={hasRegistrationDetails}
/>
{tinVerified && (
<ETradeCompanyCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)}
</StepSection>
</Stack>
)}
{step === "personnel" && (
<>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* The GM is very often the owner. Where the owner is
Fayda-verified this reuses that proven identity outright
rather than making the same human verify twice; where the
owner is backed by a typed passport there is nothing proven
to copy, so it stays a local prefill. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={toggleGmSameAsOwner}
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
identity?.owner.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
<PersonnelStep
form={form}
identity={identity}
etradeOwner={etradeOwner}
gmSameAsOwner={gmSameAsOwner}
onToggleGmSameAsOwner={toggleGmSameAsOwner}
gmLinkPending={gmLinkPending}
gmVerified={gmVerified}
/>
{/* Verifying a second person is only meaningful when the GM is
someone other than the owner. */}
{!gmSameAsOwner && identity && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={identity.gm}
required={identity.faydaRequired}
disabled={gmLinkPending}
/>
)}
{/* Typed details survive only where Fayda cannot be required —
a foreign company's manager may hold no Fayda ID. Once
verified the API owns these fields, so they go away. */}
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
<>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
</>
)}
{step === "contact" && (
<>
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{/* `gmName`, not the raw form field: a Fayda-verified GM never
fills `generalManagerName`, so gating on it hid this card from
every Ethiopian company — the majority case. */}
{gmName && (
<LinkCheckboxCard
checked={contactSameAsGm}
onToggle={toggleContactSameAsGm}
title="Same as General Manager"
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
<ContactStep
form={form}
gmName={gmName}
contactSameAsGm={contactSameAsGm}
onToggleContactSameAsGm={toggleContactSameAsGm}
/>
)}
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<TextInput
label="Position (Optional)"
placeholder="Operations Lead"
error={errors.contactPersonPosition?.message}
{...register("contactPersonPosition")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email (Optional)"
type="email"
placeholder="contact@company.com"
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
</Text>
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
<PoaStep
form={form}
identity={identity}
requirePoa={requirePoa}
delegationRequired={delegationRequired}
poaDocumentSetting={poaDocumentSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={handleDocumentFilesChange}
/>
)}
{/* A verified representative's details come from the Fayda claim
and are shown on the panel above. Where Fayda cannot be
required — a foreign company whose representative may hold no
Fayda ID — they are typed here instead. They have to be: the
API refuses to save a freight forwarder's PoA without a name,
email and phone (`REQUIRED_POA_FIELDS`), and before this the
step rendered no input for any of them, so the customer was
told to "add the poa name, poa email, poa phone" with nowhere
to add them. */}
{!identity?.poa.verified && !identity?.faydaRequired && (
<>
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Representative's Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
</SimpleGrid>
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
</>
)}
{/* The paper authorises the representative, so it shows once one
exists — or straight away for a freight forwarder, who owes it
either way and must not be failed on submit for a file the
step never offered. */}
{delegationRequired && poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={handleDocumentFilesChange}
/>
</>
)}
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !documentsSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput
file={documentsSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
containerClassName="lg:grid grid-cols-2 items-stretch"
onChange={handleDocumentFilesChange}
<DocumentsStep
loadingDocuments={loadingDocuments}
documentsSetting={documentsSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={handleDocumentFilesChange}
roleProfiles={roleProfiles}
licenseFiles={licenseFiles}
licenseFieldErrors={licenseFieldErrors}
onLicenseFilesChange={handleLicenseFilesChange}
/>
)}
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={handleLicenseFilesChange}
errors={licenseFieldErrors}
/>
</>
)}
{saveError && (
<Alert
color="red"

View File

@@ -1,328 +0,0 @@
import { Box, Button, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Building2,
CheckCircle2,
ChevronLeft,
UploadCloud,
UserRound,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import type { AuthUser } from "@/types/auth";
import type { CreateCompanyPayload } from "@/services/companies.service";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
const djiboutiSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location / Country is required"),
companyAddress: z.string().min(1, "Address is required"),
repName: z.string().min(1, "Representative name is required"),
repEmail: z.string().email("Invalid representative email"),
repPhone: z
.string()
.min(1, "Representative phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
});
type FormData = z.infer<typeof djiboutiSchema>;
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"],
representative: ["repName", "repEmail", "repPhone"],
documents: [],
confirm: [],
};
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
tin: "",
vatNumber: "",
fanNumber: "",
attributes: {
repName: data.repName,
repEmail: data.repEmail,
repPhone: data.repPhone,
},
};
}
export default function DjiboutiAgentForm({
documentSettingCode,
documentFiles: controlledFiles,
onDocumentFilesChange,
user,
onSubmit,
isPending,
onBack,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
user: AuthUser;
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
}) {
const [step, setStep] = useState<DjiboutiStep>("company");
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
);
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(djiboutiSchema),
defaultValues: {
companyName: "", companyEmail: "", companyPhone: "",
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "",
},
});
const formValues = watch();
const hasDocuments = Boolean(uploadSetting?.fields?.length);
const totalSteps = 4;
const nextStep = async () => {
if (step === "representative") { setStep("documents"); return; }
if (step === "documents") { setStep("confirm"); return; }
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
setStep("representative");
};
const skipDocuments = () => setStep("confirm");
const prevStep = () => {
if (step === "company") onBack();
else if (step === "representative") setStep("company");
else if (step === "documents") setStep("representative");
else setStep("documents");
};
const STEPS: { key: DjiboutiStep; icon: React.ReactNode }[] = [
{ key: "company", icon: <Building2 size={18} /> },
{ key: "representative", icon: <UserRound size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<DjiboutiStep, string> = {
company: `Step 1 of ${totalSteps} — Company Information`,
representative: `Step 2 of ${totalSteps} — Representative Details`,
documents: `Step 3 of ${totalSteps} — Upload Documents (Optional)`,
confirm: `Step 4 of ${totalSteps} — Review & Confirm`,
};
const stepOrder: DjiboutiStep[] = ["company", "representative", "documents", "confirm"];
const currentIdx = stepOrder.indexOf(step);
return (
<>
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
>
Change account type
</Button>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<TextInput
label="Company Name"
placeholder="Djibouti Logistics SARL"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
type="email"
placeholder="info@djib-logistics.dj"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location / Country"
placeholder="Djibouti City, Djibouti"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<TextInput
label="Address"
placeholder="Boulevard de la République"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
</>
)}
{step === "representative" && (
<>
<Text size="sm" c="edr-muted">
Provide the company representative details for this account.
</Text>
<TextInput
label="Representative Name"
placeholder="Ahmed Hassan"
error={errors.repName?.message}
{...register("repName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Representative Email"
type="email"
placeholder="ahmed@company.dj"
error={errors.repEmail?.message}
{...register("repEmail")}
/>
<ControlledPhoneField
control={control}
name="repPhone"
label="Representative Phone"
required
/>
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{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="Rep. name" value={formValues.repName} />
<ReviewRow label="Rep. email" value={formValues.repEmail} />
<ReviewRow label="Rep. phone" value={formValues.repPhone} />
</SimpleGrid>
</Box>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
</Button>
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
</Button>
</Group>
</Group>
</Stack>
</form>
</>
);
}
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

@@ -1,4 +1,16 @@
import { Box, Button, Divider, Group, Loader, Select, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
import {
Box,
Button,
Divider,
Group,
Loader,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
@@ -18,7 +30,13 @@ import type { CreateCompanyPayload } from "@/services/companies.service";
import { SmartFileInput } from "@edr/ui-common";
import { api } from "@/services/api";
const TRUCK_TYPES = ["Casoni", "Truck Trailer", "High Bed", "Low Bed", "Others"] as const;
const TRUCK_TYPES = [
"Casoni",
"Truck Trailer",
"High Bed",
"Low Bed",
"Others",
] as const;
type TransporterStep = "vehicle" | "documents" | "confirm";
@@ -36,7 +54,10 @@ const transporterSchema = z
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
})
.superRefine((data, ctx) => {
if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) {
if (
data.truckType === "Casoni" &&
(!data.plateNumber2 || data.plateNumber2.trim().length === 0)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["plateNumber2"],
@@ -50,8 +71,6 @@ type FormData = z.infer<typeof transporterSchema>;
function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
return {
companyName: user.name?.en ?? "",
companyEmail: user.email,
companyPhone: user.phoneNumber,
companyLocation: "",
companyAddress: "",
tin: data.tinNumber,
@@ -85,18 +104,36 @@ export default function TransporterForm({
onBack: () => void;
}) {
const [step, setStep] = useState<TransporterStep>("vehicle");
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
api.fileUploadSettings.getByCode.queryOptions({
input: { code: documentSettingCode },
refetchOnMount: false,
}),
);
const { register, handleSubmit, trigger, watch, control, formState: { errors } } = useForm<FormData>({
const {
register,
handleSubmit,
trigger,
watch,
control,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(transporterSchema),
defaultValues: {
tinNumber: "", fanNumber: "", truckType: "", plateNumber: "", plateNumber2: "", vehicleModel: "", yearOfManufacturing: "",
tinNumber: "",
fanNumber: "",
truckType: "",
plateNumber: "",
plateNumber2: "",
vehicleModel: "",
yearOfManufacturing: "",
},
});
@@ -107,9 +144,22 @@ export default function TransporterForm({
const totalSteps = 3;
const nextStep = async () => {
if (step === "documents") { setStep("confirm"); return; }
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
const fields: (keyof FormData)[] = ["tinNumber", "fanNumber", "truckType", "plateNumber", "vehicleModel", "yearOfManufacturing"];
if (step === "documents") {
setStep("confirm");
return;
}
if (step === "confirm") {
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
return;
}
const fields: (keyof FormData)[] = [
"tinNumber",
"fanNumber",
"truckType",
"plateNumber",
"vehicleModel",
"yearOfManufacturing",
];
const isValid = await trigger(fields);
if (!isValid) return;
setStep("documents");
@@ -152,13 +202,24 @@ export default function TransporterForm({
Change account type
</Button>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Group
justify="space-between"
align="center"
className="relative max-w-lg mx-auto px-2"
>
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
<ThemeIcon
key={key}
size={40}
radius="xl"
variant="filled"
color="edr-green"
className="relative z-10"
>
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
@@ -203,7 +264,9 @@ export default function TransporterForm({
<Divider color="edr-border" />
<Text fw={600} size="sm" c="edr-text">Vehicle / Truck Information</Text>
<Text fw={600} size="sm" c="edr-text">
Vehicle / Truck Information
</Text>
<Controller
name="truckType"
@@ -276,14 +339,23 @@ export default function TransporterForm({
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
<SmartFileInput
file={uploadSetting}
value={documentFiles}
onChange={setDocumentFiles}
/>
)}
</>
)}
{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>
<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 details below before saving.
</Text>
@@ -291,32 +363,73 @@ export default function TransporterForm({
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
<ReviewRow label="Truck Type" value={formValues.truckType} />
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
{formValues.plateNumber2 && <ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />}
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
<ReviewRow
label="Plate Number"
value={formValues.plateNumber}
/>
{formValues.plateNumber2 && (
<ReviewRow
label="Plate (Trailer)"
value={formValues.plateNumber2}
/>
)}
<ReviewRow
label="Vehicle Model"
value={formValues.vehicleModel}
/>
<ReviewRow
label="Year"
value={formValues.yearOfManufacturing}
/>
</SimpleGrid>
</Box>
)}
<Group justify="space-between" pt="xs">
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "vehicle" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
<Button
variant="default"
onClick={prevStep}
leftSection={<ArrowLeft size={16} />}
>
{step === "vehicle"
? "Change Type"
: step === "confirm"
? "Back to Documents"
: "Back"}
</Button>
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
<Button
variant="default"
onClick={skipDocuments}
disabled={isPending}
>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
onClick={
step === "confirm"
? handleSubmit((data) => onSubmit(buildPayload(data, user)))
: nextStep
}
disabled={
isPending ||
(step === "documents" && !hasDocuments && loadingDocuments)
}
loading={isPending}
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
rightSection={
!isPending && step !== "confirm" && step !== "documents" ? (
<ArrowRight size={16} />
) : undefined
}
>
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
{step === "documents"
? "Continue"
: step === "confirm"
? "Submit Registration"
: "Next Step"}
</Button>
</Group>
</Group>
@@ -329,10 +442,20 @@ export default function TransporterForm({
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
return (
<Box p={12} className="rounded-xl border border-edr-border bg-edr-card">
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
<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}>
<Text
size="sm"
fw={500}
c={value?.trim() ? "edr-text" : "edr-muted"}
mt={4}
>
{value?.trim() ? value : "Not provided"}
</Text>
</Box>

View File

@@ -4,7 +4,11 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
import { ETRADE_BUNDLE_FIELDS, type CompanyStep, type FormData } from "./schema";
import {
ETRADE_BUNDLE_FIELDS,
type CompanyStep,
type FormData,
} from "./schema";
/**
* First value that is actually present.
@@ -13,8 +17,9 @@ import { ETRADE_BUNDLE_FIELDS, type CompanyStep, type FormData } from "./schema"
* is not a value, but it isn't null either, so `??` would stop there and hand
* the form a blank it has no input to fix.
*/
export const firstPresent = (...values: (string | null | undefined)[]): string =>
values.find((v) => v && v.trim())?.trim() ?? "";
export const firstPresent = (
...values: (string | null | undefined)[]
): string => values.find((v) => v && v.trim())?.trim() ?? "";
/**
* First candidate that is actually a usable phone number, normalized to E.164.
@@ -87,8 +92,6 @@ export function buildPayload(
): CreateCompanyPayload {
return {
companyName: data.companyName,
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyAddress: data.companyAddress,
tin: data.tinNumber,
vatNumber: data.vatNumber,
@@ -129,8 +132,6 @@ export function stepPayload(
}
if (dirty.tinNumber) etrade.tin = d.tinNumber;
return {
companyEmail: d.companyEmail,
companyPhone: d.companyPhone,
companyAddress: d.companyAddress,
vatNumber: d.vatNumber,
ownerPassportNumber: d.ownerPassportNumber || undefined,
@@ -168,8 +169,6 @@ export function toFormValues(p: ProfileResponse): FormData {
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
return {
companyName: p.companyName ?? "",
companyEmail: p.companyEmail ?? "",
companyPhone: p.companyPhone ?? "",
companyAddress: p.companyAddress ?? "",
etradePhone: p.etradePhone ?? "",
tinNumber: tin,

View File

@@ -15,8 +15,6 @@ import type { CompanyIdentityState } from "@/services/verifayda.service";
const values = (over: Partial<FormData> = {}): FormData =>
({
companyName: "Acme PLC",
companyEmail: "acme@example.com",
companyPhone: "+251911223344",
companyAddress: "1, Bole, Bole, Addis Ababa",
etradePhone: "+251911223344",
tinNumber: "0012345678",
@@ -56,7 +54,9 @@ const errorFor = (data: FormData, field: keyof FormData) => {
describe("VAT number", () => {
it("accepts exactly ten digits", () => {
expect(errorFor(values({ vatNumber: "0012345678" }), "vatNumber")).toBeUndefined();
expect(
errorFor(values({ vatNumber: "0012345678" }), "vatNumber"),
).toBeUndefined();
});
// `.length(10)` used to pass this, so a ten-letter string reached the API.
@@ -87,9 +87,6 @@ describe("stepFields", () => {
// to nothing on screen.
it("never gates the company step on a derived or read-only field", () => {
const unreachable = [
"companyEmail",
"companyPhone",
"companyAddress",
"etradePhone",
"licenceNumber",
"statusDescription",
@@ -98,9 +95,9 @@ describe("stepFields", () => {
"renewalDate",
"renewedTo",
];
expect(
stepFields.company.filter((f) => unreachable.includes(f)),
).toEqual([]);
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
[],
);
});
});
@@ -140,7 +137,9 @@ describe("firstValidPhone", () => {
// "+2519", which is non-empty — so a presence check took it, put it in a field
// with no input, and the API rejected the whole step.
it("skips an eTrade number that cannot make a valid E.164", () => {
expect(firstValidPhone("09 ", "+251911223344")).toBe("+251911223344");
expect(firstValidPhone("09 ", "+251911223344")).toBe(
"+251911223344",
);
});
it("normalizes a local number it can use", () => {
@@ -165,9 +164,31 @@ describe("normalizeIdentityPhones", () => {
const identity = {
faydaRequired: true,
passportRequired: false,
owner: { verified: true, name: "A", phone: "0911223344", email: null, address: null, verifiedAt: null, passportNumber: null },
poa: { verified: false, name: null, phone: null, email: null, address: null, verifiedAt: null },
gm: { verified: false, name: null, phone: "251911223344", email: null, address: null, verifiedAt: null },
owner: {
verified: true,
name: "A",
phone: "0911223344",
email: null,
address: null,
verifiedAt: null,
passportNumber: null,
},
poa: {
verified: false,
name: null,
phone: null,
email: null,
address: null,
verifiedAt: null,
},
gm: {
verified: false,
name: null,
phone: "251911223344",
email: null,
address: null,
verifiedAt: null,
},
gmSameAsOwner: false,
complete: false,
} as CompanyIdentityState;

View File

@@ -13,11 +13,6 @@ export type CompanyStep =
export const onboardingSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
// standalone input — the granular fields live in the registration section.
companyAddress: z.string().optional(),
@@ -109,7 +104,6 @@ export type FormData = z.infer<typeof onboardingSchema>;
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
/**
* The PoA's identifying fields are never typed — they come from the Fayda
* verification, whatever the company's nationality — so nothing here requires

View File

@@ -0,0 +1,140 @@
import { SimpleGrid, Stack, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import type { CompanyRegistrationData } from "@edr/types";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import ETradeCompanyCard from "../ETradeCompanyCard";
import StepSection from "../StepSection";
export interface CompanyInfoStepProps {
form: UseFormReturn<FormData>;
/** Fayda verification state, phone-normalized by the parent. */
identity?: CompanyIdentityState;
/** True when Fayda (not a passport) is what this company must prove with. */
verifiedIdentity: boolean;
tinStatus: ETradeStatus;
tinVerified: boolean;
/** Registration fields are already populated (a lookup passed, now or earlier). */
hasRegistrationDetails: boolean;
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
onETradeStatusChange: (status: ETradeStatus) => void;
onETradeReset: () => void;
}
export default function CompanyInfoStep({
form,
identity,
verifiedIdentity,
tinStatus,
tinVerified,
hasRegistrationDetails,
onETradeDataLoaded,
onETradeStatusChange,
onETradeReset,
}: CompanyInfoStepProps) {
const {
register,
control,
watch,
formState: { errors },
} = form;
return (
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={
watch("vatNumber")?.length === 10 && !errors.vatNumber
? "done"
: "todo"
}
>
<TextInput
aria-label="VAT Number"
placeholder="0012345678"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
<StepSection
index={2}
title="Owner identity"
subtitle={
!identity?.owner.verified && !verifiedIdentity
? "Provide the company owner's passport number."
: undefined
}
status={
verifiedIdentity
? identity?.owner.verified
? "done"
: identity?.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity?.passportRequired
? "blocked"
: "todo"
}
>
{identity && (
<>
<FaydaVerifyPanel
subject="owner"
title="Owner"
state={identity.owner}
required={identity.faydaRequired}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</>
)}
</StepSection>
<StepSection
index={3}
title="Company TIN"
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={onETradeDataLoaded}
onStatusChange={onETradeStatusChange}
onReset={onETradeReset}
alreadyVerified={hasRegistrationDetails}
/>
{tinVerified && (
<ETradeCompanyCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)}
</StepSection>
</Stack>
);
}

View File

@@ -0,0 +1,79 @@
import { SimpleGrid, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface ContactStepProps {
form: UseFormReturn<FormData>;
/**
* The GM's name from whichever source established them (verification or form)
* — the "same as GM" card only makes sense once there is a GM.
*/
gmName?: string;
contactSameAsGm: boolean;
onToggleContactSameAsGm: (checked: boolean) => void;
}
export default function ContactStep({
form,
gmName,
contactSameAsGm,
onToggleContactSameAsGm,
}: ContactStepProps) {
const {
register,
control,
formState: { errors },
} = form;
return (
<>
<Text fw={600} size="sm" c="edr-text">
Contact Person
</Text>
{/* `gmName`, not the raw form field: a Fayda-verified GM never
fills `generalManagerName`, so gating on it hid this card from
every Ethiopian company — the majority case. */}
{gmName && (
<LinkCheckboxCard
checked={contactSameAsGm}
onToggle={onToggleContactSameAsGm}
title="Same as General Manager"
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
/>
)}
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<TextInput
label="Position (Optional)"
placeholder="Operations Lead"
error={errors.contactPersonPosition?.message}
{...register("contactPersonPosition")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email (Optional)"
type="email"
placeholder="contact@company.com"
error={errors.contactPersonEmail?.message}
{...register("contactPersonEmail")}
/>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
);
}

View File

@@ -0,0 +1,64 @@
import { Group, Loader, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import RoleLicenseStep, {
type RoleLicenseProfile,
} from "@/components/onboarding/RoleLicenseStep";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
export interface DocumentsStepProps {
loadingDocuments: boolean;
/** Nationality document set, minus the PoA delegation letter (its own step). */
documentsSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
roleProfiles?: RoleLicenseProfile[];
licenseFiles?: Record<string, File[]>;
licenseFieldErrors: Record<string, string>;
onLicenseFilesChange: (next: Record<string, File[]>) => void;
}
export default function DocumentsStep({
loadingDocuments,
documentsSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
roleProfiles,
licenseFiles,
licenseFieldErrors,
onLicenseFilesChange,
}: DocumentsStepProps) {
return (
<>
{loadingDocuments ? (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !documentsSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput
file={documentsSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
containerClassName="lg:grid grid-cols-2 items-stretch"
onChange={onDocumentFilesChange}
/>
)}
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseFilesChange}
errors={licenseFieldErrors}
/>
</>
);
}

View File

@@ -0,0 +1,107 @@
import { SimpleGrid, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
import { LinkCheckboxCard } from "../LinkCheckboxCard";
export interface PersonnelStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** eTrade-registered owner, once a TIN lookup has succeeded. */
etradeOwner: { name: string; phone: string } | null;
gmSameAsOwner: boolean;
onToggleGmSameAsOwner: (checked: boolean) => void;
/** A server-side "same as owner" declaration is in flight. */
gmLinkPending: boolean;
gmVerified: boolean;
}
export default function PersonnelStep({
form,
identity,
etradeOwner,
gmSameAsOwner,
onToggleGmSameAsOwner,
gmLinkPending,
gmVerified,
}: PersonnelStepProps) {
const {
register,
control,
formState: { errors },
} = form;
return (
<>
<Text fw={600} size="sm" c="edr-text">
General Manager
</Text>
{/* The GM is very often the owner. Where the owner is
Fayda-verified this reuses that proven identity outright
rather than making the same human verify twice; where the
owner is backed by a typed passport there is nothing proven
to copy, so it stays a local prefill. */}
<LinkCheckboxCard
checked={gmSameAsOwner}
onToggle={onToggleGmSameAsOwner}
title={
identity?.owner.verified
? "Same as verified owner"
: "Same as business owner"
}
description={
identity?.owner.verified
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
: etradeOwner
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
}
/>
{/* Verifying a second person is only meaningful when the GM is
someone other than the owner. */}
{!gmSameAsOwner && identity && (
<FaydaVerifyPanel
subject="gm"
title="General Manager"
state={identity.gm}
required={identity.faydaRequired}
disabled={gmLinkPending}
/>
)}
{/* Typed details survive only where Fayda cannot be required —
a foreign company's manager may hold no Fayda ID. Once
verified the API owns these fields, so they go away. */}
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
<>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
</>
);
}

View File

@@ -0,0 +1,121 @@
import { Divider, SimpleGrid, Text, TextInput } from "@mantine/core";
import type { UseFormReturn } from "react-hook-form";
import { SmartFileInput } from "@edr/ui-common";
import { ControlledPhoneField } from "@/components/PhoneField";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import type { FileUploadSetting } from "@/types/fileUploadSettings";
import type { CompanyIdentityState } from "@/services/verifayda.service";
import type { FormData } from "../schema";
export interface PoaStepProps {
form: UseFormReturn<FormData>;
identity?: CompanyIdentityState;
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
requirePoa: boolean;
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
delegationRequired: boolean;
/** Single-field upload setting carrying just the delegation letter. */
poaDocumentSetting?: FileUploadSetting;
documentFiles: Record<string, File | File[] | null>;
uploadedDocumentKeys?: string[];
documentFieldErrors: Record<string, string>;
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
}
export default function PoaStep({
form,
identity,
requirePoa,
delegationRequired,
poaDocumentSetting,
documentFiles,
uploadedDocumentKeys,
documentFieldErrors,
onDocumentFilesChange,
}: PoaStepProps) {
const {
register,
control,
formState: { errors },
} = form;
return (
<>
<Text size="sm" c="edr-muted">
{requirePoa
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
</Text>
{/* A representative acts for the company inside Ethiopia
whoever owns it, so the PoA is proven with Fayda regardless of
nationality — their name, email, phone and address all come
from the verification and are never typed here. */}
{identity && (
<FaydaVerifyPanel
subject="poa"
title="Power of Attorney"
state={identity.poa}
required={requirePoa}
/>
)}
{/* A verified representative's details come from the Fayda claim
and are shown on the panel above. Where Fayda cannot be
required — a foreign company whose representative may hold no
Fayda ID — they are typed here instead. They have to be: the
API refuses to save a freight forwarder's PoA without a name,
email and phone (`REQUIRED_POA_FIELDS`), and before this the
step rendered no input for any of them, so the customer was
told to "add the poa name, poa email, poa phone" with nowhere
to add them. */}
{!identity?.poa.verified && !identity?.faydaRequired && (
<>
<TextInput
label="Representative's Name"
placeholder="Abebe Bikila"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Representative's Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="Representative's Phone"
/>
</SimpleGrid>
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
</>
)}
{/* The paper authorises the representative, so it shows once one
exists — or straight away for a freight forwarder, who owes it
either way and must not be failed on submit for a file the
step never offered. */}
{delegationRequired && poaDocumentSetting && (
<>
<Divider my="sm" />
<SmartFileInput
file={poaDocumentSetting}
value={documentFiles}
uploadedKeys={uploadedDocumentKeys}
errors={documentFieldErrors}
onChange={onDocumentFilesChange}
/>
</>
)}
</>
);
}

View File

@@ -1,512 +0,0 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
Building2,
User,
CheckCircle2,
} from "lucide-react";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type OnboardingStep =
| "personal"
| "company"
| "representative";
const schema = z.object({
// PERSONAL
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z
.string()
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
// COMPANY
companyName: z
.string()
.min(1, "Company name is required"),
companyEmail: z
.string()
.email("Invalid company email"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z
.string()
.min(1, "Company location is required"),
companyAddress: z
.string()
.min(1, "Company address is required"),
// REPRESENTATIVE
representativeName: z
.string()
.min(1, "Representative name is required"),
representativeEmail: z
.string()
.email("Invalid representative email"),
representativePhone: z
.string()
.min(1, "Representative phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
});
type FormData = z.infer<typeof schema>;
const stepFields: Record<
OnboardingStep,
(keyof FormData)[]
> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyLocation",
"companyAddress",
],
representative: [
"representativeName",
"representativeEmail",
"representativePhone",
],
};
export default function DjiboutiForwardingAgentForm() {
const [step, setStep] =
useState<OnboardingStep>("personal");
const {
register,
control,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneNumber: "",
companyPhone: "",
representativePhone: "",
},
});
const nextStep = async () => {
if (step === "representative") {
handleSubmit(onSubmit)();
return;
}
const isValid = await trigger(
stepFields[step]
);
if (!isValid) return;
if (step === "personal") {
setStep("company");
} else {
setStep("representative");
}
};
const prevStep = () => {
if (step === "company") {
setStep("personal");
} else if (
step === "representative"
) {
setStep("company");
}
};
const onSubmit = async (
data: FormData
) => {
console.log(data);
};
return (
<>
{/* STEPPER */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-xl mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<User className="size-5" />}
active={step === "personal"}
completed={
step !== "personal"
}
/>
<StepIcon
icon={
<Building2 className="size-5" />
}
active={step === "company"}
completed={
step === "representative"
}
/>
<StepIcon
icon={<User className="size-5" />}
active={
step === "representative"
}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" &&
"Step 1 of 3 — Personal Information"}
{step === "company" &&
"Step 2 of 3 — Company Information"}
{step === "representative" &&
"Step 3 of 3 — Representative Information"}
</p>
</div>
{/* FORM */}
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.firstName
)}
>
<FieldLabel>
First Name
</FieldLabel>
<Input
placeholder="Ahmed"
{...register("firstName")}
/>
<FieldError
errors={[errors.firstName]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.lastName
)}
>
<FieldLabel>
Last Name
</FieldLabel>
<Input
placeholder="Ali"
{...register("lastName")}
/>
<FieldError
errors={[errors.lastName]}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.email
)}
>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="agent@company.com"
{...register("email")}
/>
<FieldError
errors={[errors.email]}
/>
</Field>
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
</div>
</>
)}
{/* COMPANY */}
{step === "company" && (
<>
<Field
data-invalid={Boolean(
errors.companyName
)}
>
<FieldLabel>
Company Name
</FieldLabel>
<Input
placeholder="Djibouti Freight Co."
{...register("companyName")}
/>
<FieldError
errors={[errors.companyName]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyEmail
)}
>
<FieldLabel>
Company Email
</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
{...register(
"companyEmail"
)}
/>
<FieldError
errors={[errors.companyEmail]}
/>
</Field>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyLocation
)}
>
<FieldLabel>
Company Location / Country
</FieldLabel>
<Input
placeholder="Djibouti"
{...register(
"companyLocation"
)}
/>
<FieldError
errors={[
errors.companyLocation,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.companyAddress
)}
>
<FieldLabel>
Company Address
</FieldLabel>
<Input
placeholder="Rue de Venise"
{...register(
"companyAddress"
)}
/>
<FieldError
errors={[
errors.companyAddress,
]}
/>
</Field>
</div>
</>
)}
{/* REPRESENTATIVE */}
{step ===
"representative" && (
<>
<Field
data-invalid={Boolean(
errors.representativeName
)}
>
<FieldLabel>
Company Representative Person
Name
</FieldLabel>
<Input
placeholder="Mohamed Hassan"
{...register(
"representativeName"
)}
/>
<FieldError
errors={[
errors.representativeName,
]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.representativeEmail
)}
>
<FieldLabel>
Representative Email
</FieldLabel>
<Input
type="email"
placeholder="rep@company.com"
{...register(
"representativeEmail"
)}
/>
<FieldError
errors={[
errors.representativeEmail,
]}
/>
</Field>
<ControlledPhoneField
control={control}
name="representativePhone"
label="Representative Phone"
required
/>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "personal"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting}
>
{step === "representative" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? (
<CheckCircle2 className="size-5" />
) : (
icon
)}
</div>
);
}

View File

@@ -1,711 +0,0 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
Building2,
User,
FileText,
CheckCircle2,
} from "lucide-react";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type OnboardingStep =
| "personal"
| "company"
| "personnel"
| "poa";
const onboardingSchema = z.object({
// PERSONAL
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phoneNumber: z
.string()
.min(1, "Phone number is required")
.refine(isValidPhone, "Enter a valid phone number"),
// COMPANY
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
companyAddress: z.string().min(1, "Address is required"),
// LEGAL
tinNumber: z.string().regex(/^\d{10}$/, {
message: "TIN must be exactly 10 digits",
}),
vatNumber: z.string().min(1, "VAT number is required"),
fanNumber: z.string().regex(/^\d{16}$/, {
message: "FAN must be exactly 16 digits",
}),
// CONTACT PERSON
contactPersonName: z
.string()
.min(1, "Contact person name is required"),
contactPersonPhone: z
.string()
.min(1, "Contact person phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// GENERAL MANAGER
generalManagerName: z
.string()
.min(1, "General manager name is required"),
generalManagerEmail: z
.string()
.email("Invalid email"),
generalManagerPhone: z
.string()
.min(1, "General manager phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
// OPTIONAL POA
poaName: z.string().optional(),
poaPhone: z
.string()
.optional()
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
poaAddress: z.string().optional(),
poaEmail: z.string().optional(),
poaLocation: z.string().optional(),
});
type FormData = z.infer<typeof onboardingSchema>;
const stepFields: Record<
OnboardingStep,
(keyof FormData)[]
> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
],
company: [
"companyName",
"companyEmail",
"companyPhone",
"companyLocation",
"companyAddress",
"tinNumber",
"vatNumber",
"fanNumber",
],
personnel: [
"contactPersonName",
"contactPersonPhone",
"generalManagerName",
"generalManagerEmail",
"generalManagerPhone",
],
poa: [],
};
export default function ImportExportOnBoarding() {
const [step, setStep] =
useState<OnboardingStep>("personal");
const {
register,
control,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
phoneNumber: "",
companyPhone: "",
contactPersonPhone: "",
generalManagerPhone: "",
poaPhone: "",
},
});
const nextStep = async () => {
if (step === "poa") {
handleSubmit(onSubmit)();
return;
}
const isValid = await trigger(stepFields[step]);
if (!isValid) return;
if (step === "personal") {
setStep("company");
} else if (step === "company") {
setStep("personnel");
} else {
setStep("poa");
}
};
const prevStep = () => {
if (step === "company") {
setStep("personal");
} else if (step === "personnel") {
setStep("company");
} else if (step === "poa") {
setStep("personnel");
}
};
const onSubmit = async (data: FormData) => {
console.log(data);
};
return (
<>
{/* STEP HEADER */}
<div className="mb-8">
<div className="flex items-center justify-between max-w-2xl mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<User className="size-5" />}
active={step === "personal"}
completed={
step !== "personal"
}
/>
<StepIcon
icon={<Building2 className="size-5" />}
active={step === "company"}
completed={
step === "personnel" ||
step === "poa"
}
/>
<StepIcon
icon={<User className="size-5" />}
active={step === "personnel"}
completed={step === "poa"}
/>
<StepIcon
icon={<FileText className="size-5" />}
active={step === "poa"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" &&
"Step 1 of 4 — Personal Information"}
{step === "company" &&
"Step 2 of 4 — Company Information"}
{step === "personnel" &&
"Step 3 of 4 — Personnel Information"}
{step === "poa" &&
"Step 4 of 4 — Power of Attorney"}
</p>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.firstName
)}
>
<FieldLabel>
First Name
</FieldLabel>
<Input
placeholder="John"
{...register("firstName")}
/>
<FieldError
errors={[errors.firstName]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.lastName
)}
>
<FieldLabel>
Last Name
</FieldLabel>
<Input
placeholder="Doe"
{...register("lastName")}
/>
<FieldError
errors={[errors.lastName]}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.email
)}
>
<FieldLabel>Email</FieldLabel>
<Input
type="email"
placeholder="john@example.com"
{...register("email")}
/>
<FieldError
errors={[errors.email]}
/>
</Field>
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
</div>
</>
)}
{/* COMPANY */}
{step === "company" && (
<>
<Field
data-invalid={Boolean(
errors.companyName
)}
>
<FieldLabel>
Company Name
</FieldLabel>
<Input
placeholder="Global Logistics Ltd"
{...register("companyName")}
/>
<FieldError
errors={[errors.companyName]}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyEmail
)}
>
<FieldLabel>
Company Email
</FieldLabel>
<Input
type="email"
placeholder="ops@company.com"
{...register(
"companyEmail"
)}
/>
<FieldError
errors={[errors.companyEmail]}
/>
</Field>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.companyLocation
)}
>
<FieldLabel>
Company Location
</FieldLabel>
<Input
placeholder="Addis Ababa"
{...register(
"companyLocation"
)}
/>
<FieldError
errors={[
errors.companyLocation,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.companyAddress
)}
>
<FieldLabel>
Company Address
</FieldLabel>
<Input
placeholder="Bole, Woreda 03"
{...register(
"companyAddress"
)}
/>
<FieldError
errors={[
errors.companyAddress,
]}
/>
</Field>
</div>
<div className="grid grid-cols-3 gap-4">
<Field
data-invalid={Boolean(
errors.tinNumber
)}
>
<FieldLabel>
TIN Number
</FieldLabel>
<Input
maxLength={10}
placeholder="1234567890"
{...register("tinNumber")}
/>
<FieldError
errors={[errors.tinNumber]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.vatNumber
)}
>
<FieldLabel>
VAT Number
</FieldLabel>
<Input
placeholder="VAT123456"
{...register("vatNumber")}
/>
<FieldError
errors={[errors.vatNumber]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.fanNumber
)}
>
<FieldLabel>
FAN Number
</FieldLabel>
<Input
maxLength={16}
placeholder="1234567890123456"
{...register("fanNumber")}
/>
<FieldError
errors={[errors.fanNumber]}
/>
</Field>
</div>
</>
)}
{/* PERSONNEL */}
{step === "personnel" && (
<>
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
Contact Person
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
data-invalid={Boolean(
errors.contactPersonName
)}
>
<FieldLabel>
Contact Person Name
</FieldLabel>
<Input
placeholder="Jane Smith"
{...register(
"contactPersonName"
)}
/>
<FieldError
errors={[
errors.contactPersonName,
]}
/>
</Field>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Contact Person Phone"
required
/>
</div>
</div>
<hr className="border-border" />
<div>
<h3 className="text-sm font-semibold text-foreground mb-3">
General Manager
</h3>
<div className="grid grid-cols-2 gap-4">
<Field
className="col-span-2"
data-invalid={Boolean(
errors.generalManagerName
)}
>
<FieldLabel>
General Manager Name
</FieldLabel>
<Input
placeholder="Abebe Bikila"
{...register(
"generalManagerName"
)}
/>
<FieldError
errors={[
errors.generalManagerName,
]}
/>
</Field>
<Field
data-invalid={Boolean(
errors.generalManagerEmail
)}
>
<FieldLabel>
General Manager Email
</FieldLabel>
<Input
type="email"
placeholder="gm@company.com"
{...register(
"generalManagerEmail"
)}
/>
<FieldError
errors={[
errors.generalManagerEmail,
]}
/>
</Field>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="General Manager Phone"
required
/>
</div>
</div>
</>
)}
{/* POA */}
{step === "poa" && (
<>
<p className="text-sm text-muted-foreground">
Power of Attorney details are
optional.
</p>
<Field>
<FieldLabel>PoA Name</FieldLabel>
<Input
placeholder="Authorized Representative"
{...register("poaName")}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>
PoA Email
</FieldLabel>
<Input
type="email"
placeholder="poa@company.com"
{...register("poaEmail")}
/>
</Field>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel>
PoA Location
</FieldLabel>
<Input
placeholder="City, Country"
{...register("poaLocation")}
/>
</Field>
<Field>
<FieldLabel>
PoA Address
</FieldLabel>
<Input
placeholder="Full Address"
{...register("poaAddress")}
/>
</Field>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button
type="button"
variant="outline"
onClick={prevStep}
disabled={step === "personal"}
>
<ArrowLeft />
Back
</Button>
<Button
type="button"
onClick={nextStep}
disabled={isSubmitting}
>
{step === "poa" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? (
<CheckCircle2 className="size-5" />
) : (
icon
)}
</div>
);
}

View File

@@ -1,288 +0,0 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowLeft,
ArrowRight,
User,
Truck,
CheckCircle2,
} from "lucide-react";
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import {
Button,
Field,
FieldError,
FieldGroup,
FieldLabel,
Input,
} from "@edr/ui-common";
type Step = "personal" | "transport";
const schema = z.object({
// PERSONAL
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phoneNumber: z
.string()
.min(1)
.refine(isValidPhone, "Enter a valid phone number"),
// TRANSPORT
fanNumber: z.string().min(1),
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
truckType: z.enum([
"Casoni",
"Truck Trailer",
"High Bed",
"Low Bed",
"Others",
]),
plateNumber: z.string().min(1),
plateNumber2: z.string().optional(),
vehicleModel: z.string().min(1),
yearOfManufacturing: z.string().min(1),
});
type FormData = z.infer<typeof schema>;
const stepFields: Record<Step, (keyof FormData)[]> = {
personal: [
"firstName",
"lastName",
"email",
"phoneNumber",
],
transport: [
"fanNumber",
"tinNumber",
"truckType",
"plateNumber",
"plateNumber2",
"vehicleModel",
"yearOfManufacturing",
],
};
export default function TransporterOnboarding() {
const [step, setStep] = useState<Step>("personal");
const {
register,
control,
handleSubmit,
trigger,
watch,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phoneNumber: "",
},
});
const truckType = watch("truckType");
const nextStep = async () => {
const valid = await trigger(stepFields[step]);
if (!valid) return;
if (step === "personal") setStep("transport");
else handleSubmit(onSubmit)();
};
const prevStep = () => {
if (step === "transport") setStep("personal");
};
const onSubmit = (data: FormData) => {
console.log("TRANSPORTER:", data);
};
return (
<>
{/* STEPPER */}
<div className="mb-8 lg:col-span-2">
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
<StepIcon
icon={<User className="size-5" />}
active={step === "personal"}
completed={step !== "personal"}
/>
<StepIcon
icon={<Truck className="size-5" />}
active={step === "transport"}
completed={false}
/>
</div>
<p className="text-center text-sm text-muted-foreground mt-3">
{step === "personal" && "Step 1 of 2 — Personal Information"}
{step === "transport" && "Step 2 of 2 — Transport Information"}
</p>
</div>
{/* FORM */}
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FieldGroup className="gap-4">
{/* PERSONAL */}
{step === "personal" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.firstName}>
<FieldLabel>First Name</FieldLabel>
<Input {...register("firstName")} />
<FieldError errors={[errors.firstName]} />
</Field>
<Field data-invalid={!!errors.lastName}>
<FieldLabel>Last Name</FieldLabel>
<Input {...register("lastName")} />
<FieldError errors={[errors.lastName]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.email}>
<FieldLabel>Email</FieldLabel>
<Input type="email" {...register("email")} />
<FieldError errors={[errors.email]} />
</Field>
<ControlledPhoneField
control={control}
name="phoneNumber"
label="Phone Number"
required
/>
</div>
</>
)}
{/* TRANSPORT */}
{step === "transport" && (
<>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.fanNumber}>
<FieldLabel>FAN Number</FieldLabel>
<Input {...register("fanNumber")} />
<FieldError errors={[errors.fanNumber]} />
</Field>
<Field data-invalid={!!errors.tinNumber}>
<FieldLabel>TIN Number</FieldLabel>
<Input maxLength={10} inputMode="numeric" {...register("tinNumber")} />
<FieldError errors={[errors.tinNumber]} />
</Field>
</div>
<Field data-invalid={!!errors.truckType}>
<FieldLabel>Truck Type</FieldLabel>
<select
className="w-full border rounded-md p-2 bg-background"
{...register("truckType")}
>
<option value="">Select type</option>
<option value="Casoni">Casoni</option>
<option value="Truck Trailer">Truck Trailer</option>
<option value="High Bed">High Bed</option>
<option value="Low Bed">Low Bed</option>
<option value="Others">Others</option>
</select>
<FieldError errors={[errors.truckType]} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.plateNumber}>
<FieldLabel>Plate Number</FieldLabel>
<Input {...register("plateNumber")} />
<FieldError errors={[errors.plateNumber]} />
</Field>
{truckType === "Casoni" && (
<Field data-invalid={!!errors.plateNumber2}>
<FieldLabel>Second Plate Number (Casoni)</FieldLabel>
<Input {...register("plateNumber2")} />
<FieldError errors={[errors.plateNumber2]} />
</Field>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors.vehicleModel}>
<FieldLabel>Vehicle Model</FieldLabel>
<Input {...register("vehicleModel")} />
<FieldError errors={[errors.vehicleModel]} />
</Field>
<Field data-invalid={!!errors.yearOfManufacturing}>
<FieldLabel>Year of Manufacturing</FieldLabel>
<Input {...register("yearOfManufacturing")} />
<FieldError errors={[errors.yearOfManufacturing]} />
</Field>
</div>
</>
)}
</FieldGroup>
{/* FOOTER */}
<div className="flex items-center justify-between pt-2">
<Button type="button" variant="outline" onClick={prevStep} disabled={step === "personal"}>
<ArrowLeft />
Back
</Button>
<Button type="button" onClick={nextStep} disabled={isSubmitting}>
{step === "transport" ? (
"Complete Registration"
) : (
<>
Next Step
<ArrowRight />
</>
)}
</Button>
</div>
</form>
</>
);
}
function StepIcon({
icon,
active,
completed,
}: {
icon: React.ReactNode;
active: boolean;
completed: boolean;
}) {
return (
<div
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
completed
? "bg-primary border-primary text-primary-foreground"
: active
? "bg-background border-primary text-primary shadow-md"
: "bg-background border-border text-muted-foreground"
}`}
>
{completed ? <CheckCircle2 className="size-5" /> : icon}
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { api } from "@/services/api";
import type {
CompanyProfileInput,
@@ -27,7 +26,9 @@ import { z } from "zod";
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
import OnboardingRoleSelect from "./OnboardingRoleSelect";
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
import ETradeInfo, {
type ETradeStatus,
} from "@/components/onboarding/ETradeInfo";
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
@@ -39,11 +40,6 @@ import {
export const COMPANY_PROFILE_SCHEMA = z.object({
companyName: z.string().min(1, "Company name is required"),
companyEmail: z.string().email("Invalid email address"),
companyPhone: z
.string()
.min(1, "Company phone is required")
.refine(isValidPhone, "Enter a valid phone number"),
companyLocation: z.string().min(1, "Location is required"),
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
// no standalone input.
@@ -108,8 +104,6 @@ export default function TabCompanyProfile({
if (profile) {
return {
companyName: profile.companyName,
companyEmail: profile.companyEmail ?? "",
companyPhone: profile.companyPhone ?? "",
companyLocation: profile.companyLocation,
companyAddress: profile.companyAddress ?? "",
tinNumber: profile.tinNumber,
@@ -130,8 +124,6 @@ export default function TabCompanyProfile({
}
return {
companyName: "",
companyEmail: "",
companyPhone: "",
companyLocation: "",
companyAddress: "",
tinNumber: "",
@@ -173,31 +165,6 @@ export default function TabCompanyProfile({
);
const verifiedIdentity = identity?.faydaRequired === true;
// companyEmail/companyPhone are the owner's verified contact details, never
// typed — same derivation as the onboarding wizard, just fed from the saved
// profile instead of an in-progress form. `firstValid*` rather than `??`:
// these claims are optional AND unreliable — eTrade's registered phone is
// free text that arrives as things like "09 " — and `??` stops at the
// first non-null, so junk became a read-only field the customer could not
// fix and a 400 on save. When nothing usable can be derived the fields below
// become editable instead of blocking.
const derivedEmail = firstValidEmail(identity?.owner.email, user?.email);
const derivedPhone = firstValidPhone(
identity?.owner.phone,
profile?.etradePhone,
user?.phoneNumber,
);
useEffect(() => {
if (derivedEmail) setValue("companyEmail", derivedEmail);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [derivedEmail]);
useEffect(() => {
if (derivedPhone) setValue("companyPhone", derivedPhone);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [derivedPhone]);
// companyAddress is composed from the (locked) eTrade address parts, not
// typed directly.
const region = watch("region");
@@ -221,7 +188,9 @@ export default function TabCompanyProfile({
});
}
setValue("licenceNumber", data.licenceNumber, { shouldDirty: true });
setValue("statusDescription", data.statusDescription, { shouldDirty: true });
setValue("statusDescription", data.statusDescription, {
shouldDirty: true,
});
setValue("dateRegistered", data.dateRegistered, { shouldDirty: true });
setValue("renewedFrom", data.renewedFrom, { shouldDirty: true });
setValue("renewalDate", data.renewalDate, { shouldDirty: true });
@@ -260,8 +229,6 @@ export default function TabCompanyProfile({
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
const base = {
companyEmail: data.companyEmail,
companyPhone: data.companyPhone,
companyLocation: data.companyLocation,
companyAddress: data.companyAddress,
vatNumber: data.vatNumber ?? "",
@@ -329,8 +296,11 @@ export default function TabCompanyProfile({
(mutation.isError ? extractApiError(mutation.error).message : null);
const pendingOwnerReview = Boolean(
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
?.faydaIdentity?.ownerFaydaSub,
(
profile?.pendingChanges as {
faydaIdentity?: Record<string, unknown>;
} | null
)?.faydaIdentity?.ownerFaydaSub,
);
// During onboarding the role selection gates the form: nothing else shows
@@ -413,34 +383,6 @@ export default function TabCompanyProfile({
{...register("ownerPassportNumber")}
/>
)}
{/* Read-only while the verified owner (or eTrade, or the account)
supplies them. Fayda's email/phone claims are optional, so
when nothing can be derived these become typeable — the API
requires both, and showing an empty read-only field is a save
that can never succeed. */}
<SimpleGrid cols={2} spacing="md">
{derivedEmail ? (
<ReadOnlyField label="Company email" value={derivedEmail} />
) : (
<TextInput
label="Company Email"
type="email"
description="We couldn't find one on your verified identity or account — please enter it."
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
)}
{derivedPhone ? (
<ReadOnlyField label="Company phone" value={derivedPhone} />
) : (
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
)}
</SimpleGrid>
</StepSection>
)}
@@ -449,7 +391,11 @@ export default function TabCompanyProfile({
title="Company TIN"
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
status={
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
tinVerified
? "done"
: tinStatus === "taken"
? "blocked"
: "todo"
}
>
<ETradeInfo
@@ -483,7 +429,9 @@ export default function TabCompanyProfile({
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Group gap="xs">
{mutation.isSuccess && !isCreate && (
@@ -545,7 +493,9 @@ function EtradeLockedCard({
tin: string;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
}) {
const companyName = watch("companyName");
@@ -562,10 +512,19 @@ function EtradeLockedCard({
</Text>
</Group>
<SimpleGrid cols={2} spacing="sm">
<LockedField label="Company Name" name="companyName" register={register} watch={watch} errors={errors} />
<LockedField
label="Company Name"
name="companyName"
register={register}
watch={watch}
errors={errors}
/>
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
<ReadOnlyField label="Status" value={watch("statusDescription")} />
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
<ReadOnlyField
label="Date Registered"
value={watch("dateRegistered")}
/>
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
@@ -576,10 +535,34 @@ function EtradeLockedCard({
) : (
<RegionSelect control={control} error={errors.region?.message} />
)}
<LockedField label="Zone" name="zone" register={register} watch={watch} errors={errors} />
<LockedField label="Woreda" name="woreda" register={register} watch={watch} errors={errors} />
<LockedField label="Kebele" name="kebele" register={register} watch={watch} errors={errors} />
<LockedField label="House No" name="houseNo" register={register} watch={watch} errors={errors} />
<LockedField
label="Zone"
name="zone"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Woreda"
name="woreda"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="Kebele"
name="kebele"
register={register}
watch={watch}
errors={errors}
/>
<LockedField
label="House No"
name="houseNo"
register={register}
watch={watch}
errors={errors}
/>
</SimpleGrid>
</Card>
);
@@ -596,7 +579,9 @@ function LockedField({
name: keyof CompanyProfileFormData;
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
errors: ReturnType<
typeof useForm<CompanyProfileFormData>
>["formState"]["errors"];
}) {
const value = watch(name) as string | undefined;
// A value that fails validation unlocks too — rendering a rejected value

View File

@@ -206,8 +206,6 @@ export interface CreateCompanyPayload {
companyType?: string;
nationality?: CompanyNationality;
companyName: string;
companyEmail?: string;
companyPhone?: string;
companyLocation?: string;
companyAddress?: string;
tin?: string;
@@ -460,9 +458,9 @@ export const companiesService = {
/** The current company's open profile change request (pending/rejected), or null. */
getChangeRequest: async (): Promise<ChangeRequestResponse | null> => {
const response = await client.get<ApiResponse<ChangeRequestResponse | null>>(
URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST,
);
const response = await client.get<
ApiResponse<ChangeRequestResponse | null>
>(URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST);
return unwrap(response.data);
},

View File

@@ -7,8 +7,6 @@ export interface ProfileResponse {
companyType: string;
nationality: string | null;
companyProfiles: CompanyProfileResponse[];
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
@@ -68,8 +66,6 @@ export interface ProfileResponse {
export interface UpdateProfilePayload {
nationality?: "ethiopian" | "foreign";
companyName?: string;
companyEmail?: string;
companyPhone?: string;
companyLocation?: string;
companyAddress?: string;
tin?: string;

View File

@@ -1,5 +1,9 @@
import type { BaseEntity } from "../common";
import { ClearanceNextAction, ContractDocPhase, IClearanceMilestone } from "./contracts";
import {
ClearanceNextAction,
ContractDocPhase,
IClearanceMilestone,
} from "./contracts";
export * from "./dropdown_settings";
export * from "./file_upload_settings";
@@ -183,7 +187,7 @@ export enum InvoiceSource {
FirstMile = "firstmile",
LastMile = "lastmile",
/** Customs clearance service fee — billed on the booking invoice with the freight. */
Clearance = "clearance"
Clearance = "clearance",
}
export enum SchedulingStatus {
@@ -444,8 +448,6 @@ export interface ICustomer extends BaseEntity {
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
@@ -471,8 +473,6 @@ export interface CreateCustomerDto {
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
@@ -758,7 +758,10 @@ export interface IBooking extends BaseEntity {
*/
isSplit?: boolean;
/** What this booking carried before it was reduced by a split (bulk tons / units per size). */
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
preSplitQuantities?: {
bulkTons?: number;
bySize?: Record<string, number>;
} | null;
}
export interface PricingBreakdownLineItem {