refactor: rm company profile

This commit is contained in:
Nathnael
2026-08-08 15:06:46 +00:00
parent d337fa0d85
commit 7a32bb95ff
25 changed files with 1313 additions and 2899 deletions

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

@@ -231,41 +231,39 @@ export class CompaniesService {
label: string;
get: (company: Company) => unknown;
}[] = [
{
key: "tinNumber",
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 },
{
key: "contactPersonName",
label: "Contact person name",
get: (c) => c.attributes?.contactPersonName,
},
{
key: "contactPersonPhone",
label: "Contact person phone",
get: (c) => c.attributes?.contactPersonPhone,
},
{
key: "generalManagerName",
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
{
key: "generalManagerPhone",
label: "General manager phone",
get: (c) => c.attributes?.generalManagerPhone,
},
];
{
key: "tinNumber",
label: "Company TIN",
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
},
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
{
key: "contactPersonName",
label: "Contact person name",
get: (c) => c.attributes?.contactPersonName,
},
{
key: "contactPersonPhone",
label: "Contact person phone",
get: (c) => c.attributes?.contactPersonPhone,
},
{
key: "generalManagerName",
label: "General manager name",
get: (c) => c.attributes?.generalManagerName,
},
{
key: "generalManagerEmail",
label: "General manager email",
get: (c) => c.attributes?.generalManagerEmail,
},
{
key: "generalManagerPhone",
label: "General manager phone",
get: (c) => c.attributes?.generalManagerPhone,
},
];
/** The nationality-based document setting code for a company. */
private documentSettingCodeFor(
@@ -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.",
@@ -1652,11 +1641,17 @@ export class CompaniesService {
const names = pending.map((f) => f.name).join(", ");
throw new BadRequestException(
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
`Approve it once the customer has re-uploaded them, or withdraw the change request first.`,
);
}
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"]
@@ -2088,10 +2087,10 @@ export class CompaniesService {
// verification its representative may have no way to obtain.
...((poaRequired || poaProvided) && !poaProven
? [
identity.faydaRequired
? "Verify your Power of Attorney's identity with Fayda"
: "Name your Power of Attorney, or verify them with Fayda",
]
identity.faydaRequired
? "Verify your Power of Attorney's identity with Fayda"
: "Name your Power of Attorney, or verify them with Fayda",
]
: []),
...(identity.passportRequired && !identity.owner.passportNumber
? ["Add the company owner's passport number"]
@@ -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: {
@@ -2179,7 +2181,7 @@ export class CompaniesService {
if (!requirements.isComplete) {
throw new BadRequestException(
requirements.outstanding[0] ??
"Your onboarding is incomplete. Please complete all required steps before submitting.",
"Your onboarding is incomplete. Please complete all required steps before submitting.",
);
}
@@ -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,
);
}
}
@@ -2214,12 +2219,12 @@ export class CompaniesService {
case CompanyStatus.Suspended:
throw new ForbiddenException(
`Your company account is suspended — you can't create ${action} right now. ` +
`Please contact EDR support for details.`,
`Please contact EDR support for details.`,
);
case CompanyStatus.Blacklisted:
throw new ForbiddenException(
`Your company account is blacklisted — you can't create ${action}. ` +
`Please contact EDR support.`,
`Please contact EDR support.`,
);
default:
throw new ForbiddenException(
@@ -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,
@@ -2687,7 +2688,7 @@ export class CompaniesService {
if (missing.length > 0) {
throw new BadRequestException(
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
`Add the ${missing.map((f) => f.label.toLowerCase()).join(", ")} first.`,
);
}
}
@@ -2699,13 +2700,13 @@ export class CompaniesService {
if (!onFile) {
throw new BadRequestException(
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
(opts.requirePoa ? " — it is required for freight forwarders." : "."),
);
}
if (flagged) {
throw new BadRequestException(
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
`Re-upload it before continuing.`,
`Re-upload it before continuing.`,
);
}
}
@@ -2769,7 +2770,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.`,
@@ -2976,8 +2978,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 +3391,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 +3426,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;
@@ -128,9 +124,9 @@ export class ProfileResponseDto {
const openReview =
changeRequest &&
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
(changeRequest.status === ChangeRequestStatus.Pending ||
changeRequest.status === ChangeRequestStatus.Rejected ||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
? changeRequest
: null;
this.reviewStatus =

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

@@ -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>
)}
@@ -297,8 +296,8 @@ export function ChangeRequestReview({ company }: { company: Company }) {
from={currentValue(company, key)}
to={
pending.snapshot[key] === null ||
pending.snapshot[key] === undefined ||
pending.snapshot[key] === ""
pending.snapshot[key] === undefined ||
pending.snapshot[key] === ""
? "—"
: String(pending.snapshot[key])
}
@@ -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

@@ -70,14 +70,9 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
const identity = profile.identity;
const identityIncomplete = identity
? (identity.faydaRequired && !identity.owner.verified) ||
(identity.passportRequired && !identity.owner.passportNumber)
(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>
@@ -476,110 +475,110 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
return (
<>
<Card padding="lg" radius="lg" mt="lg">
<Group gap="sm" mb="md">
<Layers size={20} />
<Title order={3}>Operational Services</Title>
</Group>
<Stack gap="sm">
{roles.map((r) => {
const status = ROLE_STATUS[r.status] ?? {
color: "gray",
label: r.status,
};
return (
<Group
key={r.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
py="xs"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
<Badge color={status.color} variant="light" radius="sm">
{status.label}
</Badge>
{r.reference && (
<Text size="xs" c="dimmed" ff="monospace">
{r.reference}
</Text>
)}
</Group>
{(r.status === "rejected" || r.status === "suspended") &&
r.reviewNote && (
<Text
size="sm"
c={r.status === "suspended" ? "orange.7" : "red.7"}
>
<strong>
{r.status === "suspended"
? "Suspension reason:"
: "Reviewer note:"}
</strong>{" "}
{r.reviewNote}
</Text>
)}
<Group gap="sm" mb="md">
<Layers size={20} />
<Title order={3}>Operational Services</Title>
</Group>
<Stack gap="sm">
{roles.map((r) => {
const status = ROLE_STATUS[r.status] ?? {
color: "gray",
label: r.status,
};
return (
<Group
key={r.id}
justify="space-between"
align="flex-start"
wrap="nowrap"
py="xs"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
<Stack gap={4}>
<Group gap="xs">
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
<Badge color={status.color} variant="light" radius="sm">
{status.label}
</Badge>
{r.reference && (
<Text size="xs" c="dimmed" ff="monospace">
{r.reference}
</Text>
)}
</Group>
{(r.status === "rejected" || r.status === "suspended") &&
r.reviewNote && (
<Text
size="sm"
c={r.status === "suspended" ? "orange.7" : "red.7"}
>
<strong>
{r.status === "suspended"
? "Suspension reason:"
: "Reviewer note:"}
</strong>{" "}
{r.reviewNote}
</Text>
)}
{r.licenseFiles.length === 0 ? (
<Text size="xs" c="edr-muted">
No license document
</Text>
) : (
<Stack gap={4}>
{r.licenseFiles.map((f) => (
<Group key={f.id} gap="xs" wrap="nowrap">
<FileText
size={14}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Anchor
component="button"
type="button"
size="xs"
lineClamp={1}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
>
{f.name}
</Anchor>
{f.status !== "live" && (
<Badge
{r.licenseFiles.length === 0 ? (
<Text size="xs" c="edr-muted">
No license document
</Text>
) : (
<Stack gap={4}>
{r.licenseFiles.map((f) => (
<Group key={f.id} gap="xs" wrap="nowrap">
<FileText
size={14}
className="text-edr-muted"
style={{ flexShrink: 0 }}
/>
<Anchor
component="button"
type="button"
size="xs"
radius="sm"
variant="light"
color={
f.status === "pending_remove" ? "red" : "yellow"
lineClamp={1}
onClick={() =>
void fetchViewableFile(f.id, f.name).then(view)
}
>
{f.status === "pending_remove"
? "Removal pending"
: "Pending"}
</Badge>
)}
</Group>
))}
</Stack>
)}
</Stack>
{f.name}
</Anchor>
{f.status !== "live" && (
<Badge
size="xs"
radius="sm"
variant="light"
color={
f.status === "pending_remove" ? "red" : "yellow"
}
>
{f.status === "pending_remove"
? "Removal pending"
: "Pending"}
</Badge>
)}
</Group>
))}
</Stack>
)}
</Stack>
{r.status === "rejected" && (
<ResubmitService
pending={resubmit.isPending}
onResubmit={(files) =>
resubmit.mutate({ profileId: r.id, files })
}
/>
)}
</Group>
);
})}
</Stack>
{r.status === "rejected" && (
<ResubmitService
pending={resubmit.isPending}
onResubmit={(files) =>
resubmit.mutate({ profileId: r.id, files })
}
/>
)}
</Group>
);
})}
</Stack>
</Card>
{viewer}
</>

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);
@@ -508,8 +450,8 @@ export default function CompanyProfileForm({
*/
const gmTyped = Boolean(
watch("generalManagerName")?.trim() &&
watch("generalManagerEmail")?.trim() &&
watch("generalManagerPhone")?.trim(),
watch("generalManagerEmail")?.trim() &&
watch("generalManagerPhone")?.trim(),
);
const gmEstablished =
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
@@ -526,8 +468,8 @@ export default function CompanyProfileForm({
*/
const poaTyped = Boolean(
watch("poaName")?.trim() &&
watch("poaEmail")?.trim() &&
watch("poaPhone")?.trim(),
watch("poaEmail")?.trim() &&
watch("poaPhone")?.trim(),
);
const poaEstablished =
(identity?.poa.verified ?? false) ||
@@ -719,8 +661,8 @@ export default function CompanyProfileForm({
const messages = parsed.success
? []
: parsed.error.issues
.filter((i) => wanted.has(String(i.path[0])))
.map((i) => i.message);
.filter((i) => wanted.has(String(i.path[0])))
.map((i) => i.message);
return messages.length > 0
? `Please fix: ${[...new Set(messages)].join(", ")}.`
: "Some details on this step are incomplete. Please review the fields above.";
@@ -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,361 +853,67 @@ 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")}
/>
</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>
<CompanyInfoStep
form={form}
identity={identity}
verifiedIdentity={verifiedIdentity}
tinStatus={tinStatus}
tinVerified={tinVerified}
hasRegistrationDetails={hasRegistrationDetails}
onETradeDataLoaded={handleETradeDataLoaded}
onETradeStatusChange={setTinStatus}
onETradeReset={handleETradeReset}
/>
)}
{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."
}
/>
{/* 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>
</>
)}
</>
<PersonnelStep
form={form}
identity={identity}
etradeOwner={etradeOwner}
gmSameAsOwner={gmSameAsOwner}
onToggleGmSameAsOwner={toggleGmSameAsOwner}
gmLinkPending={gmLinkPending}
gmVerified={gmVerified}
/>
)}
{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."
/>
)}
<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>
</>
<ContactStep
form={form}
gmName={gmName}
contactSameAsGm={contactSameAsGm}
onToggleContactSameAsGm={toggleContactSameAsGm}
/>
)}
{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}
/>
)}
{/* 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}
/>
</>
)}
</>
<PoaStep
form={form}
identity={identity}
requirePoa={requirePoa}
delegationRequired={delegationRequired}
poaDocumentSetting={poaDocumentSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={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}
/>
)}
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={handleLicenseFilesChange}
errors={licenseFieldErrors}
/>
</>
<DocumentsStep
loadingDocuments={loadingDocuments}
documentsSetting={documentsSetting}
documentFiles={documentFiles}
uploadedDocumentKeys={uploadedDocumentKeys}
documentFieldErrors={documentFieldErrors}
onDocumentFilesChange={handleDocumentFilesChange}
roleProfiles={roleProfiles}
licenseFiles={licenseFiles}
licenseFieldErrors={licenseFieldErrors}
onLicenseFilesChange={handleLicenseFilesChange}
/>
)}
{saveError && (

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,23 +1,22 @@
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
import { api } from "@/services/api";
import type {
CompanyProfileInput,
CreateCompanyPayload,
CompanyProfileInput,
CreateCompanyPayload,
} from "@/services/companies.service";
import type { AuthUser } from "@/types/auth";
import type { ProfileResponse } from "@/types/profile";
import { extractApiError } from "@/utils/result";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
Card,
Group,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
Button,
Card,
Group,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
@@ -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
@@ -346,185 +316,163 @@ export default function TabCompanyProfile({
/>
) : null}
{showForm && (
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Text c="edr-muted" size="sm" mb="lg">
{isCreate
? "Enter your company registration details to get started"
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
</Text>
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={watch("vatNumber") ? "done" : "todo"}
>
<TextInput
label="VAT Number"
placeholder="e.g. 0012345678"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
{identity && (
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
: "The company owner's passport number."
}
status={
verifiedIdentity
? identity.owner.verified
? "done"
: identity.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity.passportRequired
? "blocked"
: "todo"
}
>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={pendingOwnerReview}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...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>
)}
<StepSection
index={3}
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"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
alreadyVerified={hasRegistrationDetails}
/>
{tinVerified && (
<EtradeLockedCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)}
</StepSection>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
>
<Group gap="xs">
{mutation.isSuccess && !isCreate && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{saveErrorMessage && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{saveErrorMessage}
</Text>
</Group>
)}
<Card padding="lg">
<Group gap="sm" mb="xs">
<Building2 size={20} />
<Title order={3}>Company Profile</Title>
</Group>
<Group gap="md">
{!isCreate && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
<Text c="edr-muted" size="sm" mb="lg">
{isCreate
? "Enter your company registration details to get started"
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
</Text>
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
<Stack gap="xl">
<StepSection
index={1}
title="VAT number"
status={watch("vatNumber") ? "done" : "todo"}
>
Reset
</Button>
)}
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
<TextInput
label="VAT Number"
placeholder="e.g. 0012345678"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</StepSection>
{identity && (
<StepSection
index={2}
title="Owner identity"
subtitle={
verifiedIdentity
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
: "The company owner's passport number."
}
status={
verifiedIdentity
? identity.owner.verified
? "done"
: identity.faydaRequired
? "blocked"
: "todo"
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
? "done"
: identity.passportRequired
? "blocked"
: "todo"
}
>
<FaydaVerifyPanel
subject="owner"
title="Company owner"
state={identity.owner}
required={identity.faydaRequired}
disabled={mutation.isPending}
pendingReview={pendingOwnerReview}
/>
{identity.passportRequired && (
<TextInput
label="Owner Passport Number"
placeholder="P1234567"
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
error={errors.ownerPassportNumber?.message}
{...register("ownerPassportNumber")}
/>
)}
</StepSection>
)}
<StepSection
index={3}
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"
}
>
<ETradeInfo
tin={watch("tinNumber")}
register={register("tinNumber")}
error={errors.tinNumber?.message}
onDataLoaded={handleETradeDataLoaded}
onStatusChange={setTinStatus}
alreadyVerified={hasRegistrationDetails}
/>
{tinVerified && (
<EtradeLockedCard
tin={watch("tinNumber")}
register={register}
watch={watch}
errors={errors}
control={control}
/>
)}
</StepSection>
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
</Stack>
<Group
justify="space-between"
mt="xl"
pt="md"
style={{
borderTop: "1px solid var(--mantine-color-edr-border-0)",
}}
>
{isCreate ? "Continue" : "Save Changes"}
</Button>
</Group>
</Group>
</form>
</Card>
<Group gap="xs">
{mutation.isSuccess && !isCreate && (
<Group gap={6} c="green">
<CheckCircle2 size={16} />
<Text size="sm" fw={500}>
Saved successfully
</Text>
</Group>
)}
{saveErrorMessage && (
<Group gap={6} c="red">
<XCircle size={16} />
<Text size="sm" fw={500}>
{saveErrorMessage}
</Text>
</Group>
)}
</Group>
<Group gap="md">
{!isCreate && (
<Button
type="button"
variant="outline"
disabled={mutation.isPending || !isDirty}
onClick={() => reset()}
>
Reset
</Button>
)}
<Button
type="submit"
leftSection={<Save size={16} />}
loading={mutation.isPending}
>
{isCreate ? "Continue" : "Save Changes"}
</Button>
</Group>
</Group>
</form>
</Card>
)}
</Stack>
);
@@ -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

@@ -194,11 +194,11 @@ export interface OnboardingRequirements {
export interface CompanyProfileInput {
type:
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
| "importer"
| "exporter"
| "freight_forwarder"
| "dj_freight_forwarder"
| "transporter";
businessLicense?: string;
}
@@ -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 {