diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 810d4d46a..1dd28ca9c 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -421,7 +421,10 @@ export class CompaniesController { @CurrentUser() user: CurrentIamUser, @Body() dto: CompleteIdentityVerificationDto, ): Promise { - return this.companiesService.completeIdentityVerification(user.id, dto); + return this.companiesService.completeIdentityVerification(user.id, dto, { + email: user.email, + phoneNumber: user.phoneNumber, + }); } @Post("identity/gm/same-as-owner") @@ -434,7 +437,10 @@ export class CompaniesController { async setGmSameAsOwner( @CurrentUser() user: CurrentIamUser, ): Promise { - return this.companiesService.setGmSameAsOwner(user.id); + return this.companiesService.setGmSameAsOwner(user.id, { + email: user.email, + phoneNumber: user.phoneNumber, + }); } @Delete("identity/gm") diff --git a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts index 51dd1d61f..dd198b1c6 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.fayda-identity.spec.ts @@ -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. diff --git a/apps/edr-freight-api/src/modules/companies/companies.service.ts b/apps/edr-freight-api/src/modules/companies/companies.service.ts index eb6b28b44..2cddb2974 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.service.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.service.ts @@ -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 { 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 { + async listChangeRequests(companyId: string): Promise { await this.findCompanyById(companyId); return this.changeRequestRepo.findByCompanyId(companyId); } @@ -1150,8 +1131,7 @@ export class CompaniesService { reviewerId?: string, ): Promise { 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; 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 { 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 { 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.`, ); } } @@ -2748,6 +2749,12 @@ export class CompaniesService { async completeIdentityVerification( userId: string, dto: CompleteIdentityVerificationDto, + /** + * The signed-in account, used as the owner's fallback contact details. + * Optional so the callers that only have a user id keep compiling — they + * simply get no fallback. + */ + account?: { email?: string; phoneNumber?: string }, ): Promise { const { company } = await this.getCompanyInfoByUserId(userId); const prefix = IDENTITY_PREFIX[dto.subject]; @@ -2769,7 +2776,8 @@ export class CompaniesService { // of this check entirely. if (dto.subject === "owner" || dto.subject === "poa") { const other: IdentitySubject = dto.subject === "poa" ? "owner" : "poa"; - const otherSub = company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; + const otherSub = + company.attributes?.[`${IDENTITY_PREFIX[other]}FaydaSub`]; if (otherSub && otherSub === result.sub) { throw new BadRequestException( `This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`, @@ -2778,6 +2786,22 @@ export class CompaniesService { } const now = new Date().toISOString(); + + // Fayda's email and phone claims are optional and routinely come back empty. + // For the owner that leaves the company with no contact details at all: the + // step renders no input for them (they are the verification's output), and + // "same as owner" then copies those blanks onto `generalManagerEmail` / + // `generalManagerPhone`, which `REQUIRED_COMPANY_INFO` demands at submit — + // an unfixable dead end. The account doing the onboarding is the one contact + // we always have, and it is already OTP-proven, so it stands in. + // + // Owner only: the PoA and the GM are other people, and the registering + // account's address is not theirs to wear. + const isOwner = dto.subject === "owner"; + const email = result.email || (isOwner ? account?.email : undefined); + const phone = + result.phoneNumber || (isOwner ? account?.phoneNumber : undefined); + const identity: VerifiedIdentityAttributes = { [`${prefix}FaydaSub`]: result.sub, [`${prefix}FaydaVerifiedAt`]: now, @@ -2785,14 +2809,12 @@ export class CompaniesService { [`${prefix}Gender`]: result.gender ?? null, // The verified payload owns the person's details from here on. ...(result.fullName ? { [`${prefix}Name`]: result.fullName } : {}), - ...(result.email ? { [`${prefix}Email`]: result.email } : {}), + ...(email ? { [`${prefix}Email`]: email } : {}), // Fayda returns whatever the national registry holds, which is routinely a // local number ("0911223344"). Every typed phone in this service is stored // E.164, and `@IsValidPhone()` rejects anything else — so a raw claim here // becomes a value the portal reads back and cannot resubmit. - ...(result.phoneNumber - ? { [`${prefix}Phone`]: normalizeE164(result.phoneNumber) } - : {}), + ...(phone ? { [`${prefix}Phone`]: normalizeE164(phone) } : {}), ...(result.address ? { [`${prefix}Address`]: result.address } : {}), }; @@ -2842,7 +2864,13 @@ export class CompaniesService { * proven identity to copy, only typed text that would arrive wearing a * verified badge. */ - async setGmSameAsOwner(userId: string): Promise { + async setGmSameAsOwner( + userId: string, + /** Same fallback as {@link completeIdentityVerification}, for owners + * verified before that fallback existed — their stored contact details are + * blank, and copying blanks here would block the submit. */ + account?: { email?: string; phoneNumber?: string }, + ): Promise { const { company } = await this.getCompanyInfoByUserId(userId); const attrs = company.attributes ?? {}; const ownerSub = attrs.ownerFaydaSub as string | undefined; @@ -2852,20 +2880,23 @@ export class CompaniesService { ); } + const ownerEmail = (attrs.ownerEmail as string | undefined) || account?.email || null; + const ownerPhone = (attrs.ownerPhone as string | undefined) || account?.phoneNumber || null; + const copied: Record = { gmSameAsOwner: true, gmFaydaSub: ownerSub, gmFaydaVerifiedAt: attrs.ownerFaydaVerifiedAt ?? new Date().toISOString(), gmName: attrs.ownerName ?? null, - gmEmail: attrs.ownerEmail ?? null, - gmPhone: attrs.ownerPhone ?? null, + gmEmail: ownerEmail, + gmPhone: ownerPhone ? normalizeE164(ownerPhone) : null, gmAddress: attrs.ownerAddress ?? null, gmBirthdate: attrs.ownerBirthdate ?? null, gmGender: attrs.ownerGender ?? null, // Kept in step for the notifiers, same as a GM verification does. generalManagerName: attrs.ownerName ?? null, - generalManagerEmail: attrs.ownerEmail ?? null, - generalManagerPhone: attrs.ownerPhone ?? null, + generalManagerEmail: ownerEmail, + generalManagerPhone: ownerPhone ? normalizeE164(ownerPhone) : null, }; const updated = await this.companiesRepo.update(company.id, { @@ -2976,8 +3007,8 @@ export class CompaniesService { const snapshot = { ...(existing?.snapshot ?? {}), faydaIdentity: { - ...(((existing?.snapshot ?? {}) as Record) - .faydaIdentity ?? {}), + ...(((existing?.snapshot ?? {}) as Record).faydaIdentity ?? + {}), ...identity, }, }; @@ -3389,7 +3420,10 @@ export class CompaniesService { "We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.", ); } - return this.etradeService.extractRegistrationData(businessInfo, companyInfo); + return this.etradeService.extractRegistrationData( + businessInfo, + companyInfo, + ); } async fetchETradeData(tin: string, excludeCompanyId?: string) { @@ -3421,7 +3455,9 @@ export class CompaniesService { const tin = dto.tin ?? company.tin; const registration = await this.resolveEtradeRegistration(tin); - const fresh: Partial> = { + const fresh: Partial< + Record<(typeof ETRADE_SOURCED_FIELDS)[number], string> + > = { companyName: registration.companyName, licenceNumber: registration.licenceNumber, statusDescription: registration.statusDescription, diff --git a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts index 7816572ec..d82093336 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/create-company-with-profile.dto.ts @@ -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() diff --git a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts index f0a19dad7..a70c52b5d 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/profile-response.dto.ts @@ -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 = diff --git a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts index dc7729479..214c3858a 100644 --- a/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts +++ b/apps/edr-freight-api/src/modules/companies/dto/update-profile.dto.ts @@ -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 diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index f9e5cc2da..4f13ec6e0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -582,9 +582,7 @@ const App = () => { + } @@ -592,12 +590,7 @@ const App = () => { + } @@ -605,9 +598,7 @@ const App = () => { + } @@ -615,9 +606,7 @@ const App = () => { + } @@ -625,9 +614,7 @@ const App = () => { + } @@ -635,9 +622,7 @@ const App = () => { + } @@ -645,9 +630,7 @@ const App = () => { + } @@ -668,12 +651,7 @@ const App = () => { + } @@ -681,12 +659,7 @@ const App = () => { + } @@ -774,9 +747,7 @@ const App = () => { + } @@ -800,9 +771,7 @@ const App = () => { + } @@ -825,9 +794,7 @@ const App = () => { + } @@ -835,9 +802,7 @@ const App = () => { + } @@ -854,10 +819,7 @@ const App = () => { path="contract-templates" element={ @@ -867,10 +829,7 @@ const App = () => { path="contract-templates/:code" element={ @@ -883,7 +842,6 @@ const App = () => { permission={[ FREIGHT_PERMS.settings.supportContent.view, FREIGHT_PERMS.settings.supportContent.manage, - FREIGHT_PERMS.admin, ]} > @@ -908,9 +866,7 @@ const App = () => { + } @@ -918,9 +874,7 @@ const App = () => { +
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx index 196161f92..d86e0079c 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/ChangeRequestReview.tsx @@ -32,8 +32,6 @@ import { formatDate, humanize } from "./format"; /** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */ export const FIELD_LABELS: Record = { 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; const map: Record = { 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({ - {subject === "owner" ? "Owner re-verification" : "PoA re-verification"} + {subject === "owner" + ? "Owner re-verification" + : "PoA re-verification"} {verifiedAt && ( @@ -283,8 +282,8 @@ export function ChangeRequestReview({ company }: { company: Company }) { icon={} > Changes were requested on an earlier round of this same - submission: {pending.note} — check whether - this resubmission actually addresses it before approving. + submission: {pending.note} — check whether this + resubmission actually addresses it before approving. )} @@ -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 && ( - + )} {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 } }), ); diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index cb0910c98..e0bf818ff 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -203,22 +203,19 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Routes", href: "/dashboard/routes", icon: , - permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view], + permission: FREIGHT_PERMS.routes.view, }, { label: "Locomotives", href: "/dashboard/locomotives", icon: , - permission: [ - FREIGHT_PERMS.locomotives.view, - FREIGHT_PERMS.fleet.view, - ], + permission: FREIGHT_PERMS.locomotives.view, }, { label: "Train Builder", href: "/dashboard/train-builder", icon: , - permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view], + permission: FREIGHT_PERMS.trains.view, }, // { @@ -230,7 +227,7 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Wagons", href: "/dashboard/wagons", icon: , - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view], + permission: FREIGHT_PERMS.wagons.view, }, { label: "Wagon Transfers", @@ -287,21 +284,23 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "Compliance & Alerts", href: "/dashboard/compliance", icon: , - permission: [FREIGHT_PERMS.compliance.view, FREIGHT_PERMS.fleet.view], + permission: FREIGHT_PERMS.compliance.view, }, { label: "Incidents", href: "/dashboard/incidents", icon: , - // No dedicated backend key exists for incidents yet — stuck on the - // blanket fleet:view fallback until one is added. + // No dedicated backend key exists for incidents yet. Not part of + // the fleet.view/admin fallback cleanup — removing fleet.view + // here with nothing to replace it would lock the page to + // super-admin only, so it stays as the sole (if coarse) gate. permission: FREIGHT_PERMS.fleet.view, }, { label: "Procurement", href: "/dashboard/procurement", icon: , - permission: [FREIGHT_PERMS.procurement.view, FREIGHT_PERMS.fleet.view], + permission: FREIGHT_PERMS.procurement.view, }, { label: "Financial Reports", @@ -486,23 +485,20 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] label: "File settings", href: "/dashboard/file-settings", icon: , - permission: [FREIGHT_PERMS.settings.fileUpload.view, FREIGHT_PERMS.admin], + permission: FREIGHT_PERMS.settings.fileUpload.view, }, { label: "Dropdown settings", href: "/dashboard/dropdown-settings", icon: , - permission: [FREIGHT_PERMS.settings.dropdown.view, FREIGHT_PERMS.admin], + permission: FREIGHT_PERMS.settings.dropdown.view, }, { label: "Contract templates", href: "/dashboard/contract-templates", icon: , // `view` opens the page; `read` alone is API-only and shows no menu. - permission: [ - FREIGHT_PERMS.settings.contractTemplates.view, - FREIGHT_PERMS.admin, - ], + permission: FREIGHT_PERMS.settings.contractTemplates.view, }, { label: "Portal content", @@ -511,7 +507,6 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] permission: [ FREIGHT_PERMS.settings.supportContent.view, FREIGHT_PERMS.settings.supportContent.manage, - FREIGHT_PERMS.admin, ], }, { @@ -534,12 +529,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] { label: "Trade access", href: "/dashboard/configuration/trade-access", - permission: [FREIGHT_PERMS.tradeAccess.view, FREIGHT_PERMS.admin], + permission: FREIGHT_PERMS.tradeAccess.view, }, { label: "Exchange rate", href: "/dashboard/configuration/exchange-rate", - permission: [FREIGHT_PERMS.settings.exchangeRate.view, FREIGHT_PERMS.admin], + permission: FREIGHT_PERMS.settings.exchangeRate.view, }, ], }, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c7031a377..09790091d 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -659,19 +659,13 @@ export type FleetCrudResource = | "vehicles" | "drivers"; -/** - * Per-resource fleet CRUD check. The legacy coarse fleet:manage key still - * grants every action (mirrors the API's one-of guard fallback). - */ +/** Per-resource fleet CRUD check — each resource needs its own grant. */ export function canFleetAction( user: AuthUser | null | undefined, resource: FleetCrudResource, action: "create" | "update" | "delete", ): boolean { - return ( - hasPermission(user, FREIGHT_PERMS[resource][action]) || - hasPermission(user, FREIGHT_PERMS.fleet.manage) - ); + return hasPermission(user, FREIGHT_PERMS[resource][action]); } /** diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 12bce6aae..008149adb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -87,11 +87,7 @@ const TAB_ITEMS: Array<{ icon: TrainFront, kpiKey: "operations", metricKey: "wagonsAvailable", - permission: [ - FREIGHT_PERMS.fleet.view, - FREIGHT_PERMS.wagons.view, - FREIGHT_PERMS.trainScheduling.view, - ], + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view], }, { value: "customers", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 63f45617e..5291e26f1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -85,9 +85,7 @@ export default function TrainBuilderDetailPage() { const { user } = useAuth(); const canUpdate = canFleetAction(user, "trains", "update"); const canDelete = canFleetAction(user, "trains", "delete"); - const canAssign = - hasPermission(user, FREIGHT_PERMS.trains.assignWagons) || - hasPermission(user, FREIGHT_PERMS.fleet.manage); + const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons); const compositionQuery = useQuery( api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), diff --git a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx index df3511d8f..8a2f1d12f 100644 --- a/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/SettingsPage.tsx @@ -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. */}
- +
@@ -476,110 +475,110 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) { return ( <> - - - Operational Services - - - {roles.map((r) => { - const status = ROLE_STATUS[r.status] ?? { - color: "gray", - label: r.status, - }; - return ( - - - - {ROLE_LABELS[r.type] ?? r.type} - - {status.label} - - {r.reference && ( - - {r.reference} - - )} - - {(r.status === "rejected" || r.status === "suspended") && - r.reviewNote && ( - - - {r.status === "suspended" - ? "Suspension reason:" - : "Reviewer note:"} - {" "} - {r.reviewNote} - - )} + + + Operational Services + + + {roles.map((r) => { + const status = ROLE_STATUS[r.status] ?? { + color: "gray", + label: r.status, + }; + return ( + + + + {ROLE_LABELS[r.type] ?? r.type} + + {status.label} + + {r.reference && ( + + {r.reference} + + )} + + {(r.status === "rejected" || r.status === "suspended") && + r.reviewNote && ( + + + {r.status === "suspended" + ? "Suspension reason:" + : "Reviewer note:"} + {" "} + {r.reviewNote} + + )} - {r.licenseFiles.length === 0 ? ( - - No license document - - ) : ( - - {r.licenseFiles.map((f) => ( - - - - void fetchViewableFile(f.id, f.name).then(view) - } - > - {f.name} - - {f.status !== "live" && ( - + No license document +
+ ) : ( + + {r.licenseFiles.map((f) => ( + + + + void fetchViewableFile(f.id, f.name).then(view) } > - {f.status === "pending_remove" - ? "Removal pending" - : "Pending"} - - )} - - ))} - - )} -
+ {f.name} + + {f.status !== "live" && ( + + {f.status === "pending_remove" + ? "Removal pending" + : "Pending"} + + )} + + ))} + + )} + - {r.status === "rejected" && ( - - resubmit.mutate({ profileId: r.id, files }) - } - /> - )} - - ); - })} - + {r.status === "rejected" && ( + + resubmit.mutate({ profileId: r.id, files }) + } + /> + )} + + ); + })} + {viewer} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 15354cfd3..2405dbe5e 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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({ + const form = useForm({ 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({
e.preventDefault()}> {step === "company" && ( - - - - - - 0 - ? "done" - : identity?.passportRequired - ? "blocked" - : "todo" - } - > - {identity && ( - <> - - {identity.passportRequired && ( - - )} - {/* 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. */} - - {derivedEmail ? ( - - ) : ( - - )} - {derivedPhone ? ( - - ) : ( - - )} - - - )} - - - - - {tinVerified && ( - - )} - - + )} {step === "personnel" && ( - <> - - General Manager - - {/* 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. */} - - - {/* Verifying a second person is only meaningful when the GM is - someone other than the owner. */} - {!gmSameAsOwner && identity && ( - - )} - - {/* 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 && ( - <> - - - - - - - )} - + )} {step === "contact" && ( - <> - - Contact Person - - {/* `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 && ( - - )} - - - - - - - - - + )} {step === "poa" && ( - <> - - {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."} - - {/* 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 && ( - - )} - {/* 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 && ( - <> - - - - - - - - )} - - {/* 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 && ( - <> - - - - )} - + )} {step === "documents" && ( - <> - {loadingDocuments ? ( - - - - ) : !documentsSetting ? ( - - No document requirements found for your account type. - - ) : ( - - )} - - - + )} {saveError && ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx deleted file mode 100644 index 89570c545..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx +++ /dev/null @@ -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; - -const stepFields: Record = { - 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; - onDocumentFilesChange?: (files: Record) => void; - user: AuthUser; - onSubmit: (data: CreateCompanyPayload) => void; - isPending: boolean; - onBack: () => void; -}) { - const [step, setStep] = useState("company"); - const [internalFiles, setInternalFiles] = useState>({}); - 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({ - 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: }, - { key: "representative", icon: }, - { key: "documents", icon: }, - { key: "confirm", icon: }, - ]; - - const STEP_LABELS: Record = { - 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 ( - <> - - - - - - {STEPS.map(({ key, icon }, i) => { - const done = i < currentIdx; - const active = i === currentIdx; - return done || active ? ( - - {done ? : icon} - - ) : ( - - {icon} - - ); - })} - - - - {STEP_LABELS[step]} - - - - e.preventDefault()}> - - {step === "company" && ( - <> - - - - - - - - - - - )} - - {step === "representative" && ( - <> - - Provide the company representative details for this account. - - - - - - - - )} - - {step === "documents" && ( - <> - {loadingDocuments ? ( - - - - ) : !uploadSetting ? ( - - No document requirements found for your account type. - - ) : ( - - )} - - )} - - {step === "confirm" && ( - - Review your registration - - Confirm the company details below before saving. - - - - - - - - - - - - - )} - - - - - {step === "documents" && ( - - )} - - - - - - - ); -} - -function ReviewRow({ label, value }: { label: string; value?: string | null }) { - return ( - - - {label} - - - {value?.trim() ? value : "Not provided"} - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx index ef6f894fd..1e365ab7d 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/TransporterForm.tsx @@ -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; 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("vehicle"); - const [internalFiles, setInternalFiles] = useState>({}); + const [internalFiles, setInternalFiles] = useState< + Record + >({}); 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({ + const { + register, + handleSubmit, + trigger, + watch, + control, + formState: { errors }, + } = useForm({ 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 - + {STEPS.map(({ key, icon }, i) => { const done = i < currentIdx; const active = i === currentIdx; return done || active ? ( - + {done ? : icon} ) : ( @@ -203,7 +264,9 @@ export default function TransporterForm({ - Vehicle / Truck Information + + Vehicle / Truck Information + ) : ( - + )} )} {step === "confirm" && ( - - Review your registration + + + Review your registration + Confirm the details below before saving. @@ -291,32 +363,73 @@ export default function TransporterForm({ - - {formValues.plateNumber2 && } - - + + {formValues.plateNumber2 && ( + + )} + + )} - {step === "documents" && ( - )} @@ -329,10 +442,20 @@ export default function TransporterForm({ function ReviewRow({ label, value }: { label: string; value?: string | null }) { return ( - + {label} - + {value?.trim() ? value : "Not provided"} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts index a1dd1fa7a..572627292 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/helpers.ts @@ -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, diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts index a6f9862c8..eb5471861 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.test.ts @@ -15,8 +15,6 @@ import type { CompanyIdentityState } from "@/services/verifayda.service"; const values = (over: Partial = {}): 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; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts index 3133ddef0..82b925f7f 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/schema.ts @@ -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; /** 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 diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx new file mode 100644 index 000000000..df5021a0e --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/CompanyInfoStep.tsx @@ -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; + /** 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 ( + + + + + + 0 + ? "done" + : identity?.passportRequired + ? "blocked" + : "todo" + } + > + {identity && ( + <> + + {identity.passportRequired && ( + + )} + + )} + + + + + {tinVerified && ( + + )} + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx new file mode 100644 index 000000000..e5d16ea5f --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/ContactStep.tsx @@ -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; + /** + * 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 ( + <> + + Contact Person + + {/* `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 && ( + + )} + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/DocumentsStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/DocumentsStep.tsx new file mode 100644 index 000000000..162e46af8 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/DocumentsStep.tsx @@ -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; + uploadedDocumentKeys?: string[]; + documentFieldErrors: Record; + onDocumentFilesChange: (next: Record) => void; + roleProfiles?: RoleLicenseProfile[]; + licenseFiles?: Record; + licenseFieldErrors: Record; + onLicenseFilesChange: (next: Record) => void; +} + +export default function DocumentsStep({ + loadingDocuments, + documentsSetting, + documentFiles, + uploadedDocumentKeys, + documentFieldErrors, + onDocumentFilesChange, + roleProfiles, + licenseFiles, + licenseFieldErrors, + onLicenseFilesChange, +}: DocumentsStepProps) { + return ( + <> + {loadingDocuments ? ( + + + + ) : !documentsSetting ? ( + + No document requirements found for your account type. + + ) : ( + + )} + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx new file mode 100644 index 000000000..0c1eaa049 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PersonnelStep.tsx @@ -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; + 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 ( + <> + + General Manager + + {/* 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. */} + + + {/* Verifying a second person is only meaningful when the GM is + someone other than the owner. */} + {!gmSameAsOwner && identity && ( + + )} + + {/* 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 && ( + <> + + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx new file mode 100644 index 000000000..2c98ea473 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/companyProfileForm/steps/PoaStep.tsx @@ -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; + 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; + uploadedDocumentKeys?: string[]; + documentFieldErrors: Record; + onDocumentFilesChange: (next: Record) => void; +} + +export default function PoaStep({ + form, + identity, + requirePoa, + delegationRequired, + poaDocumentSetting, + documentFiles, + uploadedDocumentKeys, + documentFieldErrors, + onDocumentFilesChange, +}: PoaStepProps) { + const { + register, + control, + formState: { errors }, + } = form; + + return ( + <> + + {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."} + + {/* 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 && ( + + )} + {/* 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 && ( + <> + + + + + + + + )} + + {/* 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 && ( + <> + + + + )} + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx deleted file mode 100644 index f7955a0c0..000000000 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/DjiboutiFreightForwardingAgent.tsx +++ /dev/null @@ -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; - -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("personal"); - - const { - register, - control, - handleSubmit, - trigger, - formState: { errors, isSubmitting }, - } = useForm({ - 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 */} -
-
-
- - } - active={step === "personal"} - completed={ - step !== "personal" - } - /> - - - } - active={step === "company"} - completed={ - step === "representative" - } - /> - - } - active={ - step === "representative" - } - completed={false} - /> -
- -

- {step === "personal" && - "Step 1 of 3 — Personal Information"} - - {step === "company" && - "Step 2 of 3 — Company Information"} - - {step === "representative" && - "Step 3 of 3 — Representative Information"} -

-
- - {/* FORM */} -
- - {/* PERSONAL */} - {step === "personal" && ( - <> -
- - - First Name - - - - - - - - - - Last Name - - - - - - -
- -
- - Email - - - - - - - -
- - )} - - {/* COMPANY */} - {step === "company" && ( - <> - - - Company Name - - - - - - - -
- - - Company Email - - - - - - - - -
- -
- - - Company Location / Country - - - - - - - - - - Company Address - - - - - - -
- - )} - - {/* REPRESENTATIVE */} - {step === - "representative" && ( - <> - - - Company Representative Person - Name - - - - - - - -
- - - Representative Email - - - - - - - - -
- - )} -
- - {/* FOOTER */} -
- - - -
-
- - ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? ( - - ) : ( - icon - )} -
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx deleted file mode 100644 index 33a28f58d..000000000 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/ImportExportOnBoarding.tsx +++ /dev/null @@ -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; - -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("personal"); - - const { - register, - control, - handleSubmit, - trigger, - formState: { errors, isSubmitting }, - } = useForm({ - 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 */} -
-
-
- - } - active={step === "personal"} - completed={ - step !== "personal" - } - /> - - } - active={step === "company"} - completed={ - step === "personnel" || - step === "poa" - } - /> - - } - active={step === "personnel"} - completed={step === "poa"} - /> - - } - active={step === "poa"} - completed={false} - /> -
- -

- {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"} -

-
- -
- - {/* PERSONAL */} - {step === "personal" && ( - <> -
- - - First Name - - - - - - - - - - Last Name - - - - - - -
- -
- - Email - - - - - - - -
- - )} - - {/* COMPANY */} - {step === "company" && ( - <> - - - Company Name - - - - - - - -
- - - Company Email - - - - - - - - -
- -
- - - Company Location - - - - - - - - - - Company Address - - - - - - -
- -
- - - TIN Number - - - - - - - - - - VAT Number - - - - - - - - - - FAN Number - - - - - - -
- - )} - - {/* PERSONNEL */} - {step === "personnel" && ( - <> -
-

- Contact Person -

- -
- - - Contact Person Name - - - - - - - - -
-
- -
- -
-

- General Manager -

- -
- - - General Manager Name - - - - - - - - - - General Manager Email - - - - - - - - -
-
- - )} - - {/* POA */} - {step === "poa" && ( - <> -

- Power of Attorney details are - optional. -

- - - PoA Name - - - - -
- - - PoA Email - - - - - - -
- -
- - - PoA Location - - - - - - - - PoA Address - - - - -
- - )} -
- - {/* FOOTER */} -
- - - -
-
- - ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? ( - - ) : ( - icon - )} -
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx b/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx deleted file mode 100644 index 77b5d4ecd..000000000 --- a/apps/edr-freight-web/portal/src/pages/customers/on_boarding/TransportrOnBoarding.tsx +++ /dev/null @@ -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; - -const stepFields: Record = { - personal: [ - "firstName", - "lastName", - "email", - "phoneNumber", - ], - transport: [ - "fanNumber", - "tinNumber", - "truckType", - "plateNumber", - "plateNumber2", - "vehicleModel", - "yearOfManufacturing", - ], -}; - -export default function TransporterOnboarding() { - const [step, setStep] = useState("personal"); - - const { - register, - control, - handleSubmit, - trigger, - watch, - formState: { errors, isSubmitting }, - } = useForm({ - 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 */} -
-
-
- - } - active={step === "personal"} - completed={step !== "personal"} - /> - - } - active={step === "transport"} - completed={false} - /> -
- -

- {step === "personal" && "Step 1 of 2 — Personal Information"} - {step === "transport" && "Step 2 of 2 — Transport Information"} -

-
- - {/* FORM */} -
- - - {/* PERSONAL */} - {step === "personal" && ( - <> -
- - First Name - - - - - - Last Name - - - -
- -
- - Email - - - - - -
- - )} - - {/* TRANSPORT */} - {step === "transport" && ( - <> -
- - FAN Number - - - - - - TIN Number - - - -
- - - Truck Type - - - - -
- - Plate Number - - - - - {truckType === "Casoni" && ( - - Second Plate Number (Casoni) - - - - )} -
- -
- - Vehicle Model - - - - - - Year of Manufacturing - - - -
- - )} - -
- - {/* FOOTER */} -
- - - -
-
- - ); -} - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? : icon} -
- ); -} \ No newline at end of file diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 2ff16f445..519a2b95c 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -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 } | null) - ?.faydaIdentity?.ownerFaydaSub, + ( + profile?.pendingChanges as { + faydaIdentity?: Record; + } | null + )?.faydaIdentity?.ownerFaydaSub, ); // During onboarding the role selection gates the form: nothing else shows @@ -346,185 +316,163 @@ export default function TabCompanyProfile({ /> ) : null} {showForm && ( - - - - Company Profile - - - {isCreate - ? "Enter your company registration details to get started" - : "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."} - - -
- - - - - - {identity && ( - 0 - ? "done" - : identity.passportRequired - ? "blocked" - : "todo" - } - > - - {identity.passportRequired && ( - - )} - {/* 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. */} - - {derivedEmail ? ( - - ) : ( - - )} - {derivedPhone ? ( - - ) : ( - - )} - - - )} - - - - {tinVerified && ( - - )} - - - - - - - - {mutation.isSuccess && !isCreate && ( - - - - Saved successfully - - - )} - {saveErrorMessage && ( - - - - {saveErrorMessage} - - - )} + + + + Company Profile - - {!isCreate && ( - - )} - - - - -
+ + {mutation.isSuccess && !isCreate && ( + + + + Saved successfully + + + )} + {saveErrorMessage && ( + + + + {saveErrorMessage} + + + )} + + + {!isCreate && ( + + )} + + + + + )} ); @@ -545,7 +493,9 @@ function EtradeLockedCard({ tin: string; register: ReturnType>["register"]; watch: ReturnType>["watch"]; - errors: ReturnType>["formState"]["errors"]; + errors: ReturnType< + typeof useForm + >["formState"]["errors"]; control: ReturnType>["control"]; }) { const companyName = watch("companyName"); @@ -562,10 +512,19 @@ function EtradeLockedCard({ - + - + @@ -576,10 +535,34 @@ function EtradeLockedCard({ ) : ( )} - - - - + + + + ); @@ -596,7 +579,9 @@ function LockedField({ name: keyof CompanyProfileFormData; register: ReturnType>["register"]; watch: ReturnType>["watch"]; - errors: ReturnType>["formState"]["errors"]; + errors: ReturnType< + typeof useForm + >["formState"]["errors"]; }) { const value = watch(name) as string | undefined; // A value that fails validation unlocks too — rendering a rejected value diff --git a/apps/edr-freight-web/portal/src/services/companies.service.ts b/apps/edr-freight-web/portal/src/services/companies.service.ts index 5fba278dd..52f368ca6 100644 --- a/apps/edr-freight-web/portal/src/services/companies.service.ts +++ b/apps/edr-freight-web/portal/src/services/companies.service.ts @@ -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 => { - const response = await client.get>( - URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST, - ); + const response = await client.get< + ApiResponse + >(URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST); return unwrap(response.data); }, diff --git a/apps/edr-freight-web/portal/src/types/profile.ts b/apps/edr-freight-web/portal/src/types/profile.ts index 8f126ea1a..732dd13cf 100644 --- a/apps/edr-freight-web/portal/src/types/profile.ts +++ b/apps/edr-freight-web/portal/src/types/profile.ts @@ -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; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 3041f02d5..7ff14721c 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -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 } | null; + preSplitQuantities?: { + bulkTons?: number; + bySize?: Record; + } | null; } export interface PricingBreakdownLineItem {