mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor: rm company profile
This commit is contained in:
@@ -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
|
// registered phone) with nothing at all. OWNER_VERIFIED is exactly that
|
||||||
// shape: a sub, no contact details.
|
// shape: a sub, no contact details.
|
||||||
it("keeps company contact details a Fayda verification never supplied", async () => {
|
it("keeps company contact details a Fayda verification never supplied", async () => {
|
||||||
const { service, deps } = makeService({
|
const { deps } = makeService({
|
||||||
attributes: { ...OWNER_VERIFIED },
|
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)!;
|
const [, patch] = deps.companiesRepo.update.mock.calls.at(-1)!;
|
||||||
expect(patch.email).toBe("account@example.com");
|
expect(patch.email).toBe("account@example.com");
|
||||||
expect(patch.phone).toBe("+251911777777");
|
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
|
// "Same as owner" copies `ownerEmail ?? null` onto the GM while setting
|
||||||
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
// `gmFaydaSub`. Locking that null made generalManagerEmail required by
|
||||||
// onboarding, hidden by the portal's link card and unwritable at once.
|
// onboarding, hidden by the portal's link card and unwritable at once.
|
||||||
|
|||||||
@@ -231,41 +231,39 @@ export class CompaniesService {
|
|||||||
label: string;
|
label: string;
|
||||||
get: (company: Company) => unknown;
|
get: (company: Company) => unknown;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "tinNumber",
|
key: "tinNumber",
|
||||||
label: "Company TIN",
|
label: "Company TIN",
|
||||||
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
get: (c) => (c.tin && !c.tin.startsWith("D") ? c.tin : null),
|
||||||
},
|
},
|
||||||
{ key: "companyEmail", label: "Company email", get: (c) => c.email },
|
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
||||||
{ key: "companyPhone", label: "Company phone", get: (c) => c.phone },
|
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
||||||
{ key: "companyAddress", label: "Company address", get: (c) => c.address },
|
{
|
||||||
{ key: "fanNumber", label: "FAN number", get: (c) => c.fanNumber },
|
key: "contactPersonName",
|
||||||
{
|
label: "Contact person name",
|
||||||
key: "contactPersonName",
|
get: (c) => c.attributes?.contactPersonName,
|
||||||
label: "Contact person name",
|
},
|
||||||
get: (c) => c.attributes?.contactPersonName,
|
{
|
||||||
},
|
key: "contactPersonPhone",
|
||||||
{
|
label: "Contact person phone",
|
||||||
key: "contactPersonPhone",
|
get: (c) => c.attributes?.contactPersonPhone,
|
||||||
label: "Contact person phone",
|
},
|
||||||
get: (c) => c.attributes?.contactPersonPhone,
|
{
|
||||||
},
|
key: "generalManagerName",
|
||||||
{
|
label: "General manager name",
|
||||||
key: "generalManagerName",
|
get: (c) => c.attributes?.generalManagerName,
|
||||||
label: "General manager name",
|
},
|
||||||
get: (c) => c.attributes?.generalManagerName,
|
{
|
||||||
},
|
key: "generalManagerEmail",
|
||||||
{
|
label: "General manager email",
|
||||||
key: "generalManagerEmail",
|
get: (c) => c.attributes?.generalManagerEmail,
|
||||||
label: "General manager email",
|
},
|
||||||
get: (c) => c.attributes?.generalManagerEmail,
|
{
|
||||||
},
|
key: "generalManagerPhone",
|
||||||
{
|
label: "General manager phone",
|
||||||
key: "generalManagerPhone",
|
get: (c) => c.attributes?.generalManagerPhone,
|
||||||
label: "General manager phone",
|
},
|
||||||
get: (c) => c.attributes?.generalManagerPhone,
|
];
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** The nationality-based document setting code for a company. */
|
/** The nationality-based document setting code for a company. */
|
||||||
private documentSettingCodeFor(
|
private documentSettingCodeFor(
|
||||||
@@ -314,8 +312,6 @@ export class CompaniesService {
|
|||||||
fanNumber: dto.fanNumber ?? null,
|
fanNumber: dto.fanNumber ?? null,
|
||||||
country: dto.companyLocation ?? "Ethiopia",
|
country: dto.companyLocation ?? "Ethiopia",
|
||||||
address: dto.companyAddress ?? null,
|
address: dto.companyAddress ?? null,
|
||||||
phone: normalizeE164(dto.companyPhone) ?? null,
|
|
||||||
email: dto.companyEmail ?? null,
|
|
||||||
attributes: dto.attributes ?? null,
|
attributes: dto.attributes ?? null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -492,7 +488,8 @@ export class CompaniesService {
|
|||||||
async findCompanyById(id: string): Promise<Company> {
|
async findCompanyById(id: string): Promise<Company> {
|
||||||
const company = await this.companiesRepo.findById(id);
|
const company = await this.companiesRepo.findById(id);
|
||||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
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
|
// External profiles carry the onboarding flag the backoffice gates
|
||||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||||
@@ -515,9 +512,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (profile.status !== ProfileStatus.Active) {
|
if (profile.status !== ProfileStatus.Active) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException("Selected company profile is not active");
|
||||||
"Selected company profile is not active",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
@@ -760,11 +755,6 @@ export class CompaniesService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const keys: string[] = [];
|
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) {
|
for (const subject of IDENTITY_SUBJECTS) {
|
||||||
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
if (!attrs[`${IDENTITY_PREFIX[subject]}FaydaSub`]) continue;
|
||||||
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
keys.push(...IDENTITY_OWNED_FIELDS[subject].filter(held));
|
||||||
@@ -789,9 +779,6 @@ export class CompaniesService {
|
|||||||
if (dto.nationality !== undefined)
|
if (dto.nationality !== undefined)
|
||||||
companyUpdates.nationality = dto.nationality;
|
companyUpdates.nationality = dto.nationality;
|
||||||
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
|
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)
|
if (dto.companyLocation !== undefined)
|
||||||
companyUpdates.country = dto.companyLocation;
|
companyUpdates.country = dto.companyLocation;
|
||||||
if (dto.companyAddress !== undefined)
|
if (dto.companyAddress !== undefined)
|
||||||
@@ -809,7 +796,9 @@ export class CompaniesService {
|
|||||||
if (dto.contactPersonPhone !== undefined)
|
if (dto.contactPersonPhone !== undefined)
|
||||||
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
attrUpdates.contactPersonPhone = normalizeE164(dto.contactPersonPhone);
|
||||||
if (dto.contactVerifiedPhone !== undefined)
|
if (dto.contactVerifiedPhone !== undefined)
|
||||||
attrUpdates.contactVerifiedPhone = normalizeE164(dto.contactVerifiedPhone);
|
attrUpdates.contactVerifiedPhone = normalizeE164(
|
||||||
|
dto.contactVerifiedPhone,
|
||||||
|
);
|
||||||
if (dto.generalManagerName !== undefined)
|
if (dto.generalManagerName !== undefined)
|
||||||
attrUpdates.generalManagerName = dto.generalManagerName;
|
attrUpdates.generalManagerName = dto.generalManagerName;
|
||||||
if (dto.generalManagerEmail !== undefined)
|
if (dto.generalManagerEmail !== undefined)
|
||||||
@@ -820,7 +809,8 @@ export class CompaniesService {
|
|||||||
if (dto.poaPhone !== undefined)
|
if (dto.poaPhone !== undefined)
|
||||||
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
attrUpdates.poaPhone = normalizeE164(dto.poaPhone);
|
||||||
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
|
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.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
|
||||||
|
|
||||||
if (dto.licenceNumber !== undefined)
|
if (dto.licenceNumber !== undefined)
|
||||||
@@ -857,12 +847,6 @@ export class CompaniesService {
|
|||||||
Object.assign(attrUpdates, dto.faydaIdentity);
|
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
|
// Keyed on the verified VALUE, not on `ownerFaydaSub`: Fayda's email and
|
||||||
// phone claims are optional, so a verification can prove the person while
|
// phone claims are optional, so a verification can prove the person while
|
||||||
// supplying neither (see completeIdentityVerification's conditional
|
// 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
|
// forever, and re-verifying could never clear it because Fayda still has
|
||||||
// nothing to return.
|
// nothing to return.
|
||||||
if (attrUpdates.ownerFaydaSub) {
|
if (attrUpdates.ownerFaydaSub) {
|
||||||
if (attrUpdates.ownerEmail && dto.companyEmail !== undefined)
|
if (attrUpdates.ownerEmail) companyUpdates.email = attrUpdates.ownerEmail;
|
||||||
companyUpdates.email = attrUpdates.ownerEmail;
|
if (attrUpdates.ownerPhone)
|
||||||
if (attrUpdates.ownerPhone && dto.companyPhone !== undefined)
|
|
||||||
companyUpdates.phone = normalizeE164(String(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). */
|
/** List a company's change requests, newest first (backoffice review). */
|
||||||
async listChangeRequests(
|
async listChangeRequests(companyId: string): Promise<CompanyChangeRequest[]> {
|
||||||
companyId: string,
|
|
||||||
): Promise<CompanyChangeRequest[]> {
|
|
||||||
await this.findCompanyById(companyId);
|
await this.findCompanyById(companyId);
|
||||||
return this.changeRequestRepo.findByCompanyId(companyId);
|
return this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
}
|
}
|
||||||
@@ -1150,8 +1131,7 @@ export class CompaniesService {
|
|||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyChangeRequest> {
|
): Promise<CompanyChangeRequest> {
|
||||||
const request = await this.changeRequestRepo.findById(id);
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
if (!request)
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
throw new NotFoundException(`Change request ${id} not found`);
|
|
||||||
if (request.status !== ChangeRequestStatus.Pending) {
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
@@ -1164,7 +1144,10 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
const snapshot = (request.snapshot ?? {}) as Partial<UpdateProfileDto>;
|
||||||
await this.assertTinAvailable(company, snapshot.tin);
|
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.companiesRepo.update(company.id, companyUpdates);
|
||||||
await this.applyLicenseChanges(request);
|
await this.applyLicenseChanges(request);
|
||||||
await this.applyDocumentChanges(request);
|
await this.applyDocumentChanges(request);
|
||||||
@@ -1294,7 +1277,12 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (documentChanges.length > 0) {
|
if (documentChanges.length > 0) {
|
||||||
await this.recordCompanyRevision(company, {}, submittedBy, documentChanges);
|
await this.recordCompanyRevision(
|
||||||
|
company,
|
||||||
|
{},
|
||||||
|
submittedBy,
|
||||||
|
documentChanges,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return uploaded;
|
return uploaded;
|
||||||
}
|
}
|
||||||
@@ -1414,7 +1402,11 @@ export class CompaniesService {
|
|||||||
status: ChangeRequestStatus.Pending,
|
status: ChangeRequestStatus.Pending,
|
||||||
});
|
});
|
||||||
if (company) {
|
if (company) {
|
||||||
this.companyNotifier.changeRequestSubmitted(company, existing.id, false);
|
this.companyNotifier.changeRequestSubmitted(
|
||||||
|
company,
|
||||||
|
existing.id,
|
||||||
|
false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
const history = await this.changeRequestRepo.findByCompanyId(companyId);
|
||||||
@@ -1446,8 +1438,7 @@ export class CompaniesService {
|
|||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyChangeRequest> {
|
): Promise<CompanyChangeRequest> {
|
||||||
const request = await this.changeRequestRepo.findById(id);
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
if (!request)
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
throw new NotFoundException(`Change request ${id} not found`);
|
|
||||||
if (request.status !== ChangeRequestStatus.Pending) {
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
@@ -1486,8 +1477,7 @@ export class CompaniesService {
|
|||||||
reviewerId?: string,
|
reviewerId?: string,
|
||||||
): Promise<CompanyChangeRequest> {
|
): Promise<CompanyChangeRequest> {
|
||||||
const request = await this.changeRequestRepo.findById(id);
|
const request = await this.changeRequestRepo.findById(id);
|
||||||
if (!request)
|
if (!request) throw new NotFoundException(`Change request ${id} not found`);
|
||||||
throw new NotFoundException(`Change request ${id} not found`);
|
|
||||||
if (request.status !== ChangeRequestStatus.Pending) {
|
if (request.status !== ChangeRequestStatus.Pending) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Change request ${id} is already ${request.status}`,
|
`Change request ${id} is already ${request.status}`,
|
||||||
@@ -1569,10 +1559,7 @@ export class CompaniesService {
|
|||||||
const reactivating =
|
const reactivating =
|
||||||
status === ProfileStatus.Active &&
|
status === ProfileStatus.Active &&
|
||||||
existing.status === ProfileStatus.Suspended;
|
existing.status === ProfileStatus.Suspended;
|
||||||
if (
|
if ((status === ProfileStatus.Suspended || reactivating) && !note?.trim()) {
|
||||||
(status === ProfileStatus.Suspended || reactivating) &&
|
|
||||||
!note?.trim()
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
status === ProfileStatus.Suspended
|
status === ProfileStatus.Suspended
|
||||||
? "A message explaining the suspension is required — the customer will see it."
|
? "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.Pending ||
|
||||||
existing.status === ProfileStatus.Rejected;
|
existing.status === ProfileStatus.Rejected;
|
||||||
if (awaitingReview) {
|
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)) {
|
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
"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(", ");
|
const names = pending.map((f) => f.name).join(", ");
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`This role has ${pending.length} document(s) awaiting customer correction (${names}). ` +
|
`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 }));
|
.map((f) => ({ key: f.key, label: f.label }));
|
||||||
|
|
||||||
// 2. Nationality-based company documents + which are already uploaded.
|
// 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([
|
const [setting, uploadedFiles] = await Promise.all([
|
||||||
this.fileUploadSettingsService
|
this.fileUploadSettingsService
|
||||||
.getByCode(documentSettingCode)
|
.getByCode(documentSettingCode)
|
||||||
@@ -2074,7 +2071,9 @@ export class CompaniesService {
|
|||||||
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
? [`Upload the ${POA_DELEGATION_LABEL} for your Power of Attorney`]
|
||||||
: []),
|
: []),
|
||||||
...(flaggedDelegation
|
...(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
|
...(identity.faydaRequired && !identity.owner.verified
|
||||||
? ["Verify the company owner's identity with Fayda"]
|
? ["Verify the company owner's identity with Fayda"]
|
||||||
@@ -2088,10 +2087,10 @@ export class CompaniesService {
|
|||||||
// verification its representative may have no way to obtain.
|
// verification its representative may have no way to obtain.
|
||||||
...((poaRequired || poaProvided) && !poaProven
|
...((poaRequired || poaProvided) && !poaProven
|
||||||
? [
|
? [
|
||||||
identity.faydaRequired
|
identity.faydaRequired
|
||||||
? "Verify your Power of Attorney's identity with Fayda"
|
? "Verify your Power of Attorney's identity with Fayda"
|
||||||
: "Name your Power of Attorney, or verify them with Fayda",
|
: "Name your Power of Attorney, or verify them with Fayda",
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(identity.passportRequired && !identity.owner.passportNumber
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
? ["Add the company owner's passport number"]
|
? ["Add the company owner's passport number"]
|
||||||
@@ -2137,7 +2136,10 @@ export class CompaniesService {
|
|||||||
return new OnboardingRequirementsResponseDto({
|
return new OnboardingRequirementsResponseDto({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
nationality: company.nationality ?? CompanyNationality.Ethiopian,
|
||||||
companyInfo: { complete: missingInfo.length === 0, missingFields: missingInfo },
|
companyInfo: {
|
||||||
|
complete: missingInfo.length === 0,
|
||||||
|
missingFields: missingInfo,
|
||||||
|
},
|
||||||
documents,
|
documents,
|
||||||
licenseProfiles,
|
licenseProfiles,
|
||||||
poa: {
|
poa: {
|
||||||
@@ -2179,7 +2181,7 @@ export class CompaniesService {
|
|||||||
if (!requirements.isComplete) {
|
if (!requirements.isComplete) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
requirements.outstanding[0] ??
|
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);
|
const profiles = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||||
for (const cp of profiles) {
|
for (const cp of profiles) {
|
||||||
if (cp.status !== ProfileStatus.Pending) {
|
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:
|
case CompanyStatus.Suspended:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your company account is suspended — you can't create ${action} right now. ` +
|
`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:
|
case CompanyStatus.Blacklisted:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your company account is blacklisted — you can't create ${action}. ` +
|
`Your company account is blacklisted — you can't create ${action}. ` +
|
||||||
`Please contact EDR support.`,
|
`Please contact EDR support.`,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
@@ -2248,8 +2253,7 @@ export class CompaniesService {
|
|||||||
switch (profile.status) {
|
switch (profile.status) {
|
||||||
case ProfileStatus.Suspended:
|
case ProfileStatus.Suspended:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your ${role} role is suspended${
|
`Your ${role} role is suspended${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
|
||||||
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
}. Your other roles are unaffected. Please contact EDR support to resolve this.`,
|
||||||
);
|
);
|
||||||
case ProfileStatus.Blacklisted:
|
case ProfileStatus.Blacklisted:
|
||||||
@@ -2258,8 +2262,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
case ProfileStatus.Rejected:
|
case ProfileStatus.Rejected:
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(
|
||||||
`Your ${role} role was rejected${
|
`Your ${role} role was rejected${profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
||||||
profile.reviewNote ? ` — ${profile.reviewNote}` : ""
|
|
||||||
}. Amend and resubmit it from your settings page.`,
|
}. Amend and resubmit it from your settings page.`,
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -2518,9 +2521,7 @@ export class CompaniesService {
|
|||||||
LICENSE_RESOURCE,
|
LICENSE_RESOURCE,
|
||||||
);
|
);
|
||||||
return records
|
return records
|
||||||
.filter(
|
.filter((r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE)
|
||||||
(r) => r.code === LICENSE_CODE || r.code === LICENSE_PENDING_CODE,
|
|
||||||
)
|
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
@@ -2687,7 +2688,7 @@ export class CompaniesService {
|
|||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`A freight forwarder acts on other companies' behalf, so a Power of Attorney is required. ` +
|
`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) {
|
if (!onFile) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Upload the ${POA_DELEGATION_LABEL} for the Power of Attorney` +
|
`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) {
|
if (flagged) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
`The ${POA_DELEGATION_LABEL} on file needs to be corrected. ` +
|
||||||
`Re-upload it before continuing.`,
|
`Re-upload it before continuing.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2769,7 +2770,8 @@ export class CompaniesService {
|
|||||||
// of this check entirely.
|
// of this check entirely.
|
||||||
if (dto.subject === "owner" || dto.subject === "poa") {
|
if (dto.subject === "owner" || dto.subject === "poa") {
|
||||||
const other: IdentitySubject = dto.subject === "poa" ? "owner" : "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) {
|
if (otherSub && otherSub === result.sub) {
|
||||||
throw new BadRequestException(
|
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.`,
|
`This identity is already registered as the company's ${IDENTITY_LABEL[other]}. The Power of Attorney must be a different person from the owner.`,
|
||||||
@@ -2976,8 +2978,8 @@ export class CompaniesService {
|
|||||||
const snapshot = {
|
const snapshot = {
|
||||||
...(existing?.snapshot ?? {}),
|
...(existing?.snapshot ?? {}),
|
||||||
faydaIdentity: {
|
faydaIdentity: {
|
||||||
...(((existing?.snapshot ?? {}) as Record<string, any>)
|
...(((existing?.snapshot ?? {}) as Record<string, any>).faydaIdentity ??
|
||||||
.faydaIdentity ?? {}),
|
{}),
|
||||||
...identity,
|
...identity,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -3389,7 +3391,10 @@ export class CompaniesService {
|
|||||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
"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) {
|
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
||||||
@@ -3421,7 +3426,9 @@ export class CompaniesService {
|
|||||||
|
|
||||||
const tin = dto.tin ?? company.tin;
|
const tin = dto.tin ?? company.tin;
|
||||||
const registration = await this.resolveEtradeRegistration(tin);
|
const registration = await this.resolveEtradeRegistration(tin);
|
||||||
const fresh: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
const fresh: Partial<
|
||||||
|
Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>
|
||||||
|
> = {
|
||||||
companyName: registration.companyName,
|
companyName: registration.companyName,
|
||||||
licenceNumber: registration.licenceNumber,
|
licenceNumber: registration.licenceNumber,
|
||||||
statusDescription: registration.statusDescription,
|
statusDescription: registration.statusDescription,
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { IsString, IsNotEmpty, IsOptional, IsEmail, MaxLength, IsBoolean, IsEnum, IsArray, ValidateNested, ArrayMinSize } from 'class-validator';
|
import {
|
||||||
import { Type } from 'class-transformer';
|
IsString,
|
||||||
import { CompanyType } from '../entities/company.entity';
|
IsNotEmpty,
|
||||||
import { ProfileType } from '../entities/company-profile.entity';
|
IsOptional,
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
MaxLength,
|
||||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
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 {
|
export class CompanyProfileInputDto {
|
||||||
@IsEnum(ProfileType)
|
@IsEnum(ProfileType)
|
||||||
@@ -24,17 +33,6 @@ export class CreateCompanyWithProfileDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
companyName!: string;
|
companyName!: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
@MaxLength(150)
|
|
||||||
companyEmail?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
@IsValidPhone()
|
|
||||||
companyPhone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
@@ -46,7 +44,7 @@ export class CreateCompanyWithProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -2,21 +2,19 @@ import {
|
|||||||
buildCompanyIdentityState,
|
buildCompanyIdentityState,
|
||||||
CompanyIdentityStateDto,
|
CompanyIdentityStateDto,
|
||||||
} from "./complete-identity-verification.dto";
|
} from "./complete-identity-verification.dto";
|
||||||
import { Company } from '../entities/company.entity';
|
import { Company } from "../entities/company.entity";
|
||||||
import { ExternalProfile } from '../entities/external-profile.entity';
|
import { ExternalProfile } from "../entities/external-profile.entity";
|
||||||
import {
|
import {
|
||||||
ChangeRequestStatus,
|
ChangeRequestStatus,
|
||||||
CompanyChangeRequest,
|
CompanyChangeRequest,
|
||||||
} from '../entities/company-change-request.entity';
|
} from "../entities/company-change-request.entity";
|
||||||
import { ResponseCompanyProfileDto } from './response-company.dto';
|
import { ResponseCompanyProfileDto } from "./response-company.dto";
|
||||||
|
|
||||||
export class ProfileResponseDto {
|
export class ProfileResponseDto {
|
||||||
companyId: string;
|
companyId: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyType: string;
|
companyType: string;
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
companyEmail: string | null;
|
|
||||||
companyPhone: string | null;
|
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string | null;
|
companyAddress: string | null;
|
||||||
tinNumber: string;
|
tinNumber: string;
|
||||||
@@ -89,8 +87,6 @@ export class ProfileResponseDto {
|
|||||||
this.companyProfiles =
|
this.companyProfiles =
|
||||||
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
company.companyProfiles?.map((p) => new ResponseCompanyProfileDto(p)) ??
|
||||||
[];
|
[];
|
||||||
this.companyEmail = company.email ?? null;
|
|
||||||
this.companyPhone = company.phone ?? null;
|
|
||||||
this.companyLocation = company.country;
|
this.companyLocation = company.country;
|
||||||
this.companyAddress = company.address ?? null;
|
this.companyAddress = company.address ?? null;
|
||||||
this.tinNumber = company.tin;
|
this.tinNumber = company.tin;
|
||||||
@@ -128,9 +124,9 @@ export class ProfileResponseDto {
|
|||||||
|
|
||||||
const openReview =
|
const openReview =
|
||||||
changeRequest &&
|
changeRequest &&
|
||||||
(changeRequest.status === ChangeRequestStatus.Pending ||
|
(changeRequest.status === ChangeRequestStatus.Pending ||
|
||||||
changeRequest.status === ChangeRequestStatus.Rejected ||
|
changeRequest.status === ChangeRequestStatus.Rejected ||
|
||||||
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
changeRequest.status === ChangeRequestStatus.ChangesRequested)
|
||||||
? changeRequest
|
? changeRequest
|
||||||
: null;
|
: null;
|
||||||
this.reviewStatus =
|
this.reviewStatus =
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsIn,
|
IsIn,
|
||||||
Matches,
|
Matches,
|
||||||
} from 'class-validator';
|
} from "class-validator";
|
||||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from '@edr/types';
|
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
|
||||||
import { CompanyNationality } from '../entities/company.entity';
|
import { CompanyNationality } from "../entities/company.entity";
|
||||||
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
|
import { IsValidPhone } from "../../../common/validators/is-phone-number.validator";
|
||||||
import { IsTin } from '../../../common/validators/is-tin.validator';
|
import { IsTin } from "../../../common/validators/is-tin.validator";
|
||||||
|
|
||||||
export class UpdateProfileDto {
|
export class UpdateProfileDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -22,17 +22,6 @@ export class UpdateProfileDto {
|
|||||||
@MaxLength(200)
|
@MaxLength(200)
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEmail()
|
|
||||||
@MaxLength(150)
|
|
||||||
companyEmail?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(20)
|
|
||||||
@IsValidPhone()
|
|
||||||
companyPhone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
@@ -44,7 +33,7 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsTin({ message: 'TIN must be exactly 10 digits' })
|
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|
||||||
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
// Ethiopian VAT registration numbers are 10 digits, the same shape as the
|
||||||
@@ -53,7 +42,7 @@ export class UpdateProfileDto {
|
|||||||
// column may hold.
|
// column may hold.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@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;
|
vatNumber?: string;
|
||||||
|
|
||||||
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
// `fanNumber` is deliberately absent: the FAN is the Fayda number of the
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ import { formatDate, humanize } from "./format";
|
|||||||
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
/** Friendly labels for the proposed-change snapshot keys (UpdateProfileDto). */
|
||||||
export const FIELD_LABELS: Record<string, string> = {
|
export const FIELD_LABELS: Record<string, string> = {
|
||||||
companyName: "Company name",
|
companyName: "Company name",
|
||||||
companyEmail: "Company email",
|
|
||||||
companyPhone: "Company phone",
|
|
||||||
companyLocation: "Location",
|
companyLocation: "Location",
|
||||||
companyAddress: "Address",
|
companyAddress: "Address",
|
||||||
tin: "TIN",
|
tin: "TIN",
|
||||||
@@ -73,8 +71,6 @@ export function currentValue(company: Company, key: string): string {
|
|||||||
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
const attrs = (company.attributes ?? {}) as Record<string, unknown>;
|
||||||
const map: Record<string, unknown> = {
|
const map: Record<string, unknown> = {
|
||||||
companyName: c.name,
|
companyName: c.name,
|
||||||
companyEmail: c.email,
|
|
||||||
companyPhone: c.phone,
|
|
||||||
companyLocation: c.country,
|
companyLocation: c.country,
|
||||||
companyAddress: c.address,
|
companyAddress: c.address,
|
||||||
tin: c.tin,
|
tin: c.tin,
|
||||||
@@ -118,7 +114,8 @@ function FaydaIdentityDiff({
|
|||||||
if (!subject) return null;
|
if (!subject) return null;
|
||||||
const current =
|
const current =
|
||||||
subject === "owner" ? company.identity?.owner : company.identity?.poa;
|
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 verifiedAt = read("FaydaVerifiedAt");
|
||||||
const fields: { label: string; from?: string | null; to?: string }[] = [
|
const fields: { label: string; from?: string | null; to?: string }[] = [
|
||||||
{ label: "Name", from: current?.name, to: read("Name") },
|
{ label: "Name", from: current?.name, to: read("Name") },
|
||||||
@@ -131,7 +128,9 @@ function FaydaIdentityDiff({
|
|||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
<Group gap={8}>
|
<Group gap={8}>
|
||||||
<Text size="sm" fw={600} c="edr-text">
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
|
{subject === "owner"
|
||||||
|
? "Owner re-verification"
|
||||||
|
: "PoA re-verification"}
|
||||||
</Text>
|
</Text>
|
||||||
{verifiedAt && (
|
{verifiedAt && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -283,8 +282,8 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
icon={<AlertTriangle size={16} />}
|
icon={<AlertTriangle size={16} />}
|
||||||
>
|
>
|
||||||
Changes were requested on an earlier round of this same
|
Changes were requested on an earlier round of this same
|
||||||
submission: <strong>{pending.note}</strong> — check whether
|
submission: <strong>{pending.note}</strong> — check whether this
|
||||||
this resubmission actually addresses it before approving.
|
resubmission actually addresses it before approving.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -297,8 +296,8 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
from={currentValue(company, key)}
|
from={currentValue(company, key)}
|
||||||
to={
|
to={
|
||||||
pending.snapshot[key] === null ||
|
pending.snapshot[key] === null ||
|
||||||
pending.snapshot[key] === undefined ||
|
pending.snapshot[key] === undefined ||
|
||||||
pending.snapshot[key] === ""
|
pending.snapshot[key] === ""
|
||||||
? "—"
|
? "—"
|
||||||
: String(pending.snapshot[key])
|
: String(pending.snapshot[key])
|
||||||
}
|
}
|
||||||
@@ -312,7 +311,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{faydaIdentitySnapshot && (
|
{faydaIdentitySnapshot && (
|
||||||
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
|
<FaydaIdentityDiff
|
||||||
|
company={company}
|
||||||
|
snapshot={faydaIdentitySnapshot}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{documentChanges.length > 0 && (
|
{documentChanges.length > 0 && (
|
||||||
@@ -373,9 +375,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void fetchViewableFile(fileId, `Document ${i + 1}`).then(
|
void fetchViewableFile(
|
||||||
view,
|
fileId,
|
||||||
)
|
`Document ${i + 1}`,
|
||||||
|
).then(view)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Document {i + 1}
|
Document {i + 1}
|
||||||
@@ -446,7 +449,10 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color="yellow"
|
color="yellow"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActionTarget({ id: pending.id, kind: "request-changes" });
|
setActionTarget({
|
||||||
|
id: pending.id,
|
||||||
|
kind: "request-changes",
|
||||||
|
});
|
||||||
setNote("");
|
setNote("");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -525,7 +531,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Compact "N changes pending" pill for the customer list/detail header. */
|
/** 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(
|
const query = useQuery(
|
||||||
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
|
api.customers.changeRequests.queryOptions({ input: { id: companyId } }),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,14 +70,9 @@ function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
|||||||
const identity = profile.identity;
|
const identity = profile.identity;
|
||||||
const identityIncomplete = identity
|
const identityIncomplete = identity
|
||||||
? (identity.faydaRequired && !identity.owner.verified) ||
|
? (identity.faydaRequired && !identity.owner.verified) ||
|
||||||
(identity.passportRequired && !identity.owner.passportNumber)
|
(identity.passportRequired && !identity.owner.passportNumber)
|
||||||
: false;
|
: false;
|
||||||
return (
|
return !profile.companyAddress || identityIncomplete;
|
||||||
!profile.companyEmail ||
|
|
||||||
!profile.companyPhone ||
|
|
||||||
!profile.companyAddress ||
|
|
||||||
identityIncomplete
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
case "contact":
|
case "contact":
|
||||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||||
@@ -399,7 +394,11 @@ export default function SettingsPage() {
|
|||||||
itself, not the panel. */}
|
itself, not the panel. */}
|
||||||
<Tabs.Panel value="company">
|
<Tabs.Panel value="company">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
<TabCompanyProfile
|
||||||
|
mode="edit"
|
||||||
|
profile={profile}
|
||||||
|
user={user ?? undefined}
|
||||||
|
/>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
<OperationalServicesCard profile={profile} />
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -476,110 +475,110 @@ function OperationalServicesCard({ profile }: { profile: ProfileResponse }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Card padding="lg" radius="lg" mt="lg">
|
<Card padding="lg" radius="lg" mt="lg">
|
||||||
<Group gap="sm" mb="md">
|
<Group gap="sm" mb="md">
|
||||||
<Layers size={20} />
|
<Layers size={20} />
|
||||||
<Title order={3}>Operational Services</Title>
|
<Title order={3}>Operational Services</Title>
|
||||||
</Group>
|
</Group>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{roles.map((r) => {
|
{roles.map((r) => {
|
||||||
const status = ROLE_STATUS[r.status] ?? {
|
const status = ROLE_STATUS[r.status] ?? {
|
||||||
color: "gray",
|
color: "gray",
|
||||||
label: r.status,
|
label: r.status,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
key={r.id}
|
key={r.id}
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
wrap="nowrap"
|
wrap="nowrap"
|
||||||
py="xs"
|
py="xs"
|
||||||
style={{
|
style={{
|
||||||
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
|
<Text fw={600}>{ROLE_LABELS[r.type] ?? r.type}</Text>
|
||||||
<Badge color={status.color} variant="light" radius="sm">
|
<Badge color={status.color} variant="light" radius="sm">
|
||||||
{status.label}
|
{status.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
{r.reference && (
|
{r.reference && (
|
||||||
<Text size="xs" c="dimmed" ff="monospace">
|
<Text size="xs" c="dimmed" ff="monospace">
|
||||||
{r.reference}
|
{r.reference}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
{(r.status === "rejected" || r.status === "suspended") &&
|
{(r.status === "rejected" || r.status === "suspended") &&
|
||||||
r.reviewNote && (
|
r.reviewNote && (
|
||||||
<Text
|
<Text
|
||||||
size="sm"
|
size="sm"
|
||||||
c={r.status === "suspended" ? "orange.7" : "red.7"}
|
c={r.status === "suspended" ? "orange.7" : "red.7"}
|
||||||
>
|
>
|
||||||
<strong>
|
<strong>
|
||||||
{r.status === "suspended"
|
{r.status === "suspended"
|
||||||
? "Suspension reason:"
|
? "Suspension reason:"
|
||||||
: "Reviewer note:"}
|
: "Reviewer note:"}
|
||||||
</strong>{" "}
|
</strong>{" "}
|
||||||
{r.reviewNote}
|
{r.reviewNote}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{r.licenseFiles.length === 0 ? (
|
{r.licenseFiles.length === 0 ? (
|
||||||
<Text size="xs" c="edr-muted">
|
<Text size="xs" c="edr-muted">
|
||||||
No license document
|
No license document
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{r.licenseFiles.map((f) => (
|
{r.licenseFiles.map((f) => (
|
||||||
<Group key={f.id} gap="xs" wrap="nowrap">
|
<Group key={f.id} gap="xs" wrap="nowrap">
|
||||||
<FileText
|
<FileText
|
||||||
size={14}
|
size={14}
|
||||||
className="text-edr-muted"
|
className="text-edr-muted"
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
<Anchor
|
<Anchor
|
||||||
component="button"
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
size="xs"
|
|
||||||
lineClamp={1}
|
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(f.id, f.name).then(view)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{f.name}
|
|
||||||
</Anchor>
|
|
||||||
{f.status !== "live" && (
|
|
||||||
<Badge
|
|
||||||
size="xs"
|
size="xs"
|
||||||
radius="sm"
|
lineClamp={1}
|
||||||
variant="light"
|
onClick={() =>
|
||||||
color={
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
f.status === "pending_remove" ? "red" : "yellow"
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{f.status === "pending_remove"
|
{f.name}
|
||||||
? "Removal pending"
|
</Anchor>
|
||||||
: "Pending"}
|
{f.status !== "live" && (
|
||||||
</Badge>
|
<Badge
|
||||||
)}
|
size="xs"
|
||||||
</Group>
|
radius="sm"
|
||||||
))}
|
variant="light"
|
||||||
</Stack>
|
color={
|
||||||
)}
|
f.status === "pending_remove" ? "red" : "yellow"
|
||||||
</Stack>
|
}
|
||||||
|
>
|
||||||
|
{f.status === "pending_remove"
|
||||||
|
? "Removal pending"
|
||||||
|
: "Pending"}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
{r.status === "rejected" && (
|
{r.status === "rejected" && (
|
||||||
<ResubmitService
|
<ResubmitService
|
||||||
pending={resubmit.isPending}
|
pending={resubmit.isPending}
|
||||||
onResubmit={(files) =>
|
onResubmit={(files) =>
|
||||||
resubmit.mutate({ profileId: r.id, files })
|
resubmit.mutate({ profileId: r.id, files })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
{viewer}
|
{viewer}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { Alert, Button, Group, Stack } from "@mantine/core";
|
||||||
Alert,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Loader,
|
|
||||||
SimpleGrid,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
TextInput,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
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 { CreateCompanyPayload } from "@/services/companies.service";
|
||||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||||
import type { CompanyRegistrationData } from "@edr/types";
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
import { toEthiopianE164 } from "@/components/PhoneField";
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
|
||||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import RoleLicenseStep, {
|
import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep";
|
||||||
type RoleLicenseProfile,
|
import type { ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
||||||
} from "@/components/onboarding/RoleLicenseStep";
|
|
||||||
import ETradeInfo, {
|
|
||||||
type ETradeStatus,
|
|
||||||
} from "@/components/onboarding/ETradeInfo";
|
|
||||||
import {
|
import {
|
||||||
buildOnboardingSchema,
|
buildOnboardingSchema,
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
@@ -45,13 +30,13 @@ import {
|
|||||||
stepPayload,
|
stepPayload,
|
||||||
toFormValues,
|
toFormValues,
|
||||||
} from "./companyProfileForm/helpers";
|
} from "./companyProfileForm/helpers";
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
|
||||||
import { verifaydaService } from "@/services/verifayda.service";
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
|
||||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
import PersonnelStep from "./companyProfileForm/steps/PersonnelStep";
|
||||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
import ContactStep from "./companyProfileForm/steps/ContactStep";
|
||||||
import StepSection from "./companyProfileForm/StepSection";
|
import PoaStep from "./companyProfileForm/steps/PoaStep";
|
||||||
|
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
|
||||||
|
|
||||||
export default function CompanyProfileForm({
|
export default function CompanyProfileForm({
|
||||||
documentSettingCode,
|
documentSettingCode,
|
||||||
@@ -199,15 +184,7 @@ export default function CompanyProfileForm({
|
|||||||
// forms (plus a mandatory owner passport number).
|
// forms (plus a mandatory owner passport number).
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
const verifiedIdentity = identity?.faydaRequired === true;
|
||||||
|
|
||||||
const {
|
const form = useForm<FormData>({
|
||||||
register,
|
|
||||||
control,
|
|
||||||
trigger,
|
|
||||||
watch,
|
|
||||||
setValue,
|
|
||||||
getValues,
|
|
||||||
formState: { errors, dirtyFields },
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
buildOnboardingSchema(identity?.passportRequired === true),
|
buildOnboardingSchema(identity?.passportRequired === true),
|
||||||
),
|
),
|
||||||
@@ -218,8 +195,6 @@ export default function CompanyProfileForm({
|
|||||||
resetOptions: { keepDirtyValues: true, keepErrors: true },
|
resetOptions: { keepDirtyValues: true, keepErrors: true },
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
|
||||||
companyPhone: "",
|
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
etradePhone: "",
|
etradePhone: "",
|
||||||
tinNumber: "",
|
tinNumber: "",
|
||||||
@@ -253,6 +228,16 @@ export default function CompanyProfileForm({
|
|||||||
values: rehydrate ? toFormValues(rehydrate) : undefined,
|
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.
|
// The contact person's email still just seeds from the account and stays editable.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.email) return;
|
if (!user?.email) return;
|
||||||
@@ -348,38 +333,6 @@ export default function CompanyProfileForm({
|
|||||||
setEtradeOwner(null);
|
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
|
// "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
|
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
@@ -400,14 +353,6 @@ export default function CompanyProfileForm({
|
|||||||
}, [identity]);
|
}, [identity]);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
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
|
// 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
|
// higher-trust source, and the whole point of proving identity is to stop
|
||||||
// trusting typed/looked-up data for this.
|
// trusting typed/looked-up data for this.
|
||||||
@@ -417,17 +362,12 @@ export default function CompanyProfileForm({
|
|||||||
user.name?.en,
|
user.name?.en,
|
||||||
);
|
);
|
||||||
|
|
||||||
const gmSourceEmail = firstValidEmail(
|
const gmSourceEmail = firstValidEmail(identity?.owner.email, user.email);
|
||||||
identity?.owner.email,
|
|
||||||
companyEmail,
|
|
||||||
user.email,
|
|
||||||
);
|
|
||||||
// Same reason as `derivedPhone`: this value is written into
|
// Same reason as `derivedPhone`: this value is written into
|
||||||
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
|
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
|
||||||
// unusable eTrade number here 400s the personnel step instead.
|
// unusable eTrade number here 400s the personnel step instead.
|
||||||
const gmSourcePhone = firstValidPhone(
|
const gmSourcePhone = firstValidPhone(
|
||||||
identity?.owner.phone,
|
identity?.owner.phone,
|
||||||
companyPhone,
|
|
||||||
etradeOwner?.phone,
|
etradeOwner?.phone,
|
||||||
user.phoneNumber,
|
user.phoneNumber,
|
||||||
);
|
);
|
||||||
@@ -480,7 +420,9 @@ export default function CompanyProfileForm({
|
|||||||
setSaveError(
|
setSaveError(
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
?.message ??
|
?.message ??
|
||||||
(err instanceof Error ? err.message : "Could not update the general manager"),
|
(err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Could not update the general manager"),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setGmLinkPending(false);
|
setGmLinkPending(false);
|
||||||
@@ -508,8 +450,8 @@ export default function CompanyProfileForm({
|
|||||||
*/
|
*/
|
||||||
const gmTyped = Boolean(
|
const gmTyped = Boolean(
|
||||||
watch("generalManagerName")?.trim() &&
|
watch("generalManagerName")?.trim() &&
|
||||||
watch("generalManagerEmail")?.trim() &&
|
watch("generalManagerEmail")?.trim() &&
|
||||||
watch("generalManagerPhone")?.trim(),
|
watch("generalManagerPhone")?.trim(),
|
||||||
);
|
);
|
||||||
const gmEstablished =
|
const gmEstablished =
|
||||||
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||||
@@ -526,8 +468,8 @@ export default function CompanyProfileForm({
|
|||||||
*/
|
*/
|
||||||
const poaTyped = Boolean(
|
const poaTyped = Boolean(
|
||||||
watch("poaName")?.trim() &&
|
watch("poaName")?.trim() &&
|
||||||
watch("poaEmail")?.trim() &&
|
watch("poaEmail")?.trim() &&
|
||||||
watch("poaPhone")?.trim(),
|
watch("poaPhone")?.trim(),
|
||||||
);
|
);
|
||||||
const poaEstablished =
|
const poaEstablished =
|
||||||
(identity?.poa.verified ?? false) ||
|
(identity?.poa.verified ?? false) ||
|
||||||
@@ -719,8 +661,8 @@ export default function CompanyProfileForm({
|
|||||||
const messages = parsed.success
|
const messages = parsed.success
|
||||||
? []
|
? []
|
||||||
: parsed.error.issues
|
: parsed.error.issues
|
||||||
.filter((i) => wanted.has(String(i.path[0])))
|
.filter((i) => wanted.has(String(i.path[0])))
|
||||||
.map((i) => i.message);
|
.map((i) => i.message);
|
||||||
return messages.length > 0
|
return messages.length > 0
|
||||||
? `Please fix: ${[...new Set(messages)].join(", ")}.`
|
? `Please fix: ${[...new Set(messages)].join(", ")}.`
|
||||||
: "Some details on this step are incomplete. Please review the fields above.";
|
: "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)[] => {
|
const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => {
|
||||||
if (s !== "company" || !identity) return stepFields[s];
|
if (s !== "company" || !identity) return stepFields[s];
|
||||||
return [
|
return [...stepFields.company];
|
||||||
...stepFields.company,
|
|
||||||
...(derivedEmail ? [] : (["companyEmail"] as const)),
|
|
||||||
...(derivedPhone ? [] : (["companyPhone"] as const)),
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Validate + persist the current step, returning whether we may advance. */
|
/** Validate + persist the current step, returning whether we may advance. */
|
||||||
@@ -753,9 +691,7 @@ export default function CompanyProfileForm({
|
|||||||
if (!onSaveStep) return true;
|
if (!onSaveStep) return true;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await onSaveStep(
|
const res = await onSaveStep(stepPayload(step, getValues(), dirtyFields));
|
||||||
stepPayload(step, getValues(), dirtyFields),
|
|
||||||
);
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setSaveError(res.error);
|
setSaveError(res.error);
|
||||||
return false;
|
return false;
|
||||||
@@ -828,8 +764,14 @@ export default function CompanyProfileForm({
|
|||||||
// saveCurrentStep()'s trigger() below catches that; checking the stale
|
// saveCurrentStep()'s trigger() below catches that; checking the stale
|
||||||
// server-side identity.owner.passportNumber here would block a value the
|
// server-side identity.owner.passportNumber here would block a value the
|
||||||
// user just typed but hasn't saved yet.
|
// user just typed but hasn't saved yet.
|
||||||
if (step === "company" && identity?.faydaRequired && !identity.owner.verified) {
|
if (
|
||||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
step === "company" &&
|
||||||
|
identity?.faydaRequired &&
|
||||||
|
!identity.owner.verified
|
||||||
|
) {
|
||||||
|
setSaveError(
|
||||||
|
"Verify the company owner's identity with Fayda before continuing.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The GM is established through Fayda now, so the step gates on the
|
// 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.
|
// which is unreachable while this save keeps failing.
|
||||||
if (step === "poa" && delegationRequired && onUploadDocuments) {
|
if (step === "poa" && delegationRequired && onUploadDocuments) {
|
||||||
const pending = documentFiles[POA_DELEGATION_FILE_KEY];
|
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) {
|
if (hasPending) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
@@ -909,361 +853,67 @@ export default function CompanyProfileForm({
|
|||||||
<form onSubmit={(e) => e.preventDefault()}>
|
<form onSubmit={(e) => e.preventDefault()}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{step === "company" && (
|
{step === "company" && (
|
||||||
<Stack gap="xl">
|
<CompanyInfoStep
|
||||||
<StepSection
|
form={form}
|
||||||
index={1}
|
identity={identity}
|
||||||
title="VAT number"
|
verifiedIdentity={verifiedIdentity}
|
||||||
status={
|
tinStatus={tinStatus}
|
||||||
watch("vatNumber")?.length === 10 && !errors.vatNumber
|
tinVerified={tinVerified}
|
||||||
? "done"
|
hasRegistrationDetails={hasRegistrationDetails}
|
||||||
: "todo"
|
onETradeDataLoaded={handleETradeDataLoaded}
|
||||||
}
|
onETradeStatusChange={setTinStatus}
|
||||||
>
|
onETradeReset={handleETradeReset}
|
||||||
<TextInput
|
/>
|
||||||
aria-label="VAT Number"
|
|
||||||
placeholder="0012345678"
|
|
||||||
maxLength={10}
|
|
||||||
error={errors.vatNumber?.message}
|
|
||||||
{...register("vatNumber")}
|
|
||||||
/>
|
|
||||||
</StepSection>
|
|
||||||
|
|
||||||
<StepSection
|
|
||||||
index={2}
|
|
||||||
title="Owner identity"
|
|
||||||
subtitle={
|
|
||||||
!identity?.owner.verified && !verifiedIdentity
|
|
||||||
? "Provide the company owner's passport number."
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
status={
|
|
||||||
verifiedIdentity
|
|
||||||
? identity?.owner.verified
|
|
||||||
? "done"
|
|
||||||
: identity?.faydaRequired
|
|
||||||
? "blocked"
|
|
||||||
: "todo"
|
|
||||||
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
|
||||||
? "done"
|
|
||||||
: identity?.passportRequired
|
|
||||||
? "blocked"
|
|
||||||
: "todo"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{identity && (
|
|
||||||
<>
|
|
||||||
<FaydaVerifyPanel
|
|
||||||
subject="owner"
|
|
||||||
title="Owner"
|
|
||||||
state={identity.owner}
|
|
||||||
required={identity.faydaRequired}
|
|
||||||
/>
|
|
||||||
{identity.passportRequired && (
|
|
||||||
<TextInput
|
|
||||||
label="Owner Passport Number"
|
|
||||||
placeholder="P1234567"
|
|
||||||
error={errors.ownerPassportNumber?.message}
|
|
||||||
{...register("ownerPassportNumber")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Normally derived from the verified owner (falling back
|
|
||||||
to eTrade and the account), and shown read-only. Fayda's
|
|
||||||
email and phone claims are optional though, so when
|
|
||||||
every source comes up empty these become typeable —
|
|
||||||
the API requires both at submit, and having no input
|
|
||||||
for them is otherwise an unrecoverable dead end. */}
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
{derivedEmail ? (
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Company email"
|
|
||||||
value={derivedEmail}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextInput
|
|
||||||
label="Company Email"
|
|
||||||
type="email"
|
|
||||||
description="We couldn't find one on your verified identity or account — please enter it."
|
|
||||||
placeholder="company@example.com"
|
|
||||||
error={errors.companyEmail?.message}
|
|
||||||
{...register("companyEmail")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{derivedPhone ? (
|
|
||||||
<ReadOnlyField
|
|
||||||
label="Company phone"
|
|
||||||
value={derivedPhone}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</StepSection>
|
|
||||||
|
|
||||||
<StepSection
|
|
||||||
index={3}
|
|
||||||
title="Company TIN"
|
|
||||||
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
|
||||||
status={
|
|
||||||
tinVerified
|
|
||||||
? "done"
|
|
||||||
: tinStatus === "taken"
|
|
||||||
? "blocked"
|
|
||||||
: "todo"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ETradeInfo
|
|
||||||
tin={watch("tinNumber")}
|
|
||||||
register={register("tinNumber")}
|
|
||||||
error={errors.tinNumber?.message}
|
|
||||||
onDataLoaded={handleETradeDataLoaded}
|
|
||||||
onStatusChange={setTinStatus}
|
|
||||||
onReset={handleETradeReset}
|
|
||||||
alreadyVerified={hasRegistrationDetails}
|
|
||||||
/>
|
|
||||||
{tinVerified && (
|
|
||||||
<ETradeCompanyCard
|
|
||||||
tin={watch("tinNumber")}
|
|
||||||
register={register}
|
|
||||||
watch={watch}
|
|
||||||
errors={errors}
|
|
||||||
control={control}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</StepSection>
|
|
||||||
</Stack>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "personnel" && (
|
{step === "personnel" && (
|
||||||
<>
|
<PersonnelStep
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
form={form}
|
||||||
General Manager
|
identity={identity}
|
||||||
</Text>
|
etradeOwner={etradeOwner}
|
||||||
{/* The GM is very often the owner. Where the owner is
|
gmSameAsOwner={gmSameAsOwner}
|
||||||
Fayda-verified this reuses that proven identity outright
|
onToggleGmSameAsOwner={toggleGmSameAsOwner}
|
||||||
rather than making the same human verify twice; where the
|
gmLinkPending={gmLinkPending}
|
||||||
owner is backed by a typed passport there is nothing proven
|
gmVerified={gmVerified}
|
||||||
to copy, so it stays a local prefill. */}
|
/>
|
||||||
<LinkCheckboxCard
|
|
||||||
checked={gmSameAsOwner}
|
|
||||||
onToggle={toggleGmSameAsOwner}
|
|
||||||
title={
|
|
||||||
identity?.owner.verified
|
|
||||||
? "Same as verified owner"
|
|
||||||
: "Same as business owner"
|
|
||||||
}
|
|
||||||
description={
|
|
||||||
identity?.owner.verified
|
|
||||||
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
|
||||||
: etradeOwner
|
|
||||||
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
|
||||||
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Verifying a second person is only meaningful when the GM is
|
|
||||||
someone other than the owner. */}
|
|
||||||
{!gmSameAsOwner && identity && (
|
|
||||||
<FaydaVerifyPanel
|
|
||||||
subject="gm"
|
|
||||||
title="General Manager"
|
|
||||||
state={identity.gm}
|
|
||||||
required={identity.faydaRequired}
|
|
||||||
disabled={gmLinkPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Typed details survive only where Fayda cannot be required —
|
|
||||||
a foreign company's manager may hold no Fayda ID. Once
|
|
||||||
verified the API owns these fields, so they go away. */}
|
|
||||||
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label="Name"
|
|
||||||
placeholder="Abebe Bikila"
|
|
||||||
error={errors.generalManagerName?.message}
|
|
||||||
{...register("generalManagerName")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="gm@company.com"
|
|
||||||
error={errors.generalManagerEmail?.message}
|
|
||||||
{...register("generalManagerEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="generalManagerPhone"
|
|
||||||
label="Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "contact" && (
|
{step === "contact" && (
|
||||||
<>
|
<ContactStep
|
||||||
<Text fw={600} size="sm" c="edr-text">
|
form={form}
|
||||||
Contact Person
|
gmName={gmName}
|
||||||
</Text>
|
contactSameAsGm={contactSameAsGm}
|
||||||
{/* `gmName`, not the raw form field: a Fayda-verified GM never
|
onToggleContactSameAsGm={toggleContactSameAsGm}
|
||||||
fills `generalManagerName`, so gating on it hid this card from
|
/>
|
||||||
every Ethiopian company — the majority case. */}
|
|
||||||
{gmName && (
|
|
||||||
<LinkCheckboxCard
|
|
||||||
checked={contactSameAsGm}
|
|
||||||
onToggle={toggleContactSameAsGm}
|
|
||||||
title="Same as General Manager"
|
|
||||||
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Name"
|
|
||||||
placeholder="Jane Smith"
|
|
||||||
error={errors.contactPersonName?.message}
|
|
||||||
{...register("contactPersonName")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Position (Optional)"
|
|
||||||
placeholder="Operations Lead"
|
|
||||||
error={errors.contactPersonPosition?.message}
|
|
||||||
{...register("contactPersonPosition")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Email (Optional)"
|
|
||||||
type="email"
|
|
||||||
placeholder="contact@company.com"
|
|
||||||
error={errors.contactPersonEmail?.message}
|
|
||||||
{...register("contactPersonEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="contactPersonPhone"
|
|
||||||
label="Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "poa" && (
|
{step === "poa" && (
|
||||||
<>
|
<PoaStep
|
||||||
<Text size="sm" c="edr-muted">
|
form={form}
|
||||||
{requirePoa
|
identity={identity}
|
||||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
requirePoa={requirePoa}
|
||||||
: "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."}
|
delegationRequired={delegationRequired}
|
||||||
</Text>
|
poaDocumentSetting={poaDocumentSetting}
|
||||||
{/* A representative acts for the company inside Ethiopia
|
documentFiles={documentFiles}
|
||||||
whoever owns it, so the PoA is proven with Fayda regardless of
|
uploadedDocumentKeys={uploadedDocumentKeys}
|
||||||
nationality — their name, email, phone and address all come
|
documentFieldErrors={documentFieldErrors}
|
||||||
from the verification and are never typed here. */}
|
onDocumentFilesChange={handleDocumentFilesChange}
|
||||||
{identity && (
|
/>
|
||||||
<FaydaVerifyPanel
|
|
||||||
subject="poa"
|
|
||||||
title="Power of Attorney"
|
|
||||||
state={identity.poa}
|
|
||||||
required={requirePoa}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* A verified representative's details come from the Fayda claim
|
|
||||||
and are shown on the panel above. Where Fayda cannot be
|
|
||||||
required — a foreign company whose representative may hold no
|
|
||||||
Fayda ID — they are typed here instead. They have to be: the
|
|
||||||
API refuses to save a freight forwarder's PoA without a name,
|
|
||||||
email and phone (`REQUIRED_POA_FIELDS`), and before this the
|
|
||||||
step rendered no input for any of them, so the customer was
|
|
||||||
told to "add the poa name, poa email, poa phone" with nowhere
|
|
||||||
to add them. */}
|
|
||||||
{!identity?.poa.verified && !identity?.faydaRequired && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label="Representative's Name"
|
|
||||||
placeholder="Abebe Bikila"
|
|
||||||
error={errors.poaName?.message}
|
|
||||||
{...register("poaName")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Representative's Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="poa@company.com"
|
|
||||||
error={errors.poaEmail?.message}
|
|
||||||
{...register("poaEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="poaPhone"
|
|
||||||
label="Representative's Phone"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Location"
|
|
||||||
placeholder="City, Country"
|
|
||||||
error={errors.poaLocation?.message}
|
|
||||||
{...register("poaLocation")}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* The paper authorises the representative, so it shows once one
|
|
||||||
exists — or straight away for a freight forwarder, who owes it
|
|
||||||
either way and must not be failed on submit for a file the
|
|
||||||
step never offered. */}
|
|
||||||
{delegationRequired && poaDocumentSetting && (
|
|
||||||
<>
|
|
||||||
<Divider my="sm" />
|
|
||||||
<SmartFileInput
|
|
||||||
file={poaDocumentSetting}
|
|
||||||
value={documentFiles}
|
|
||||||
uploadedKeys={uploadedDocumentKeys}
|
|
||||||
errors={documentFieldErrors}
|
|
||||||
onChange={handleDocumentFilesChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "documents" && (
|
{step === "documents" && (
|
||||||
<>
|
<DocumentsStep
|
||||||
{loadingDocuments ? (
|
loadingDocuments={loadingDocuments}
|
||||||
<Group justify="center" py="xl">
|
documentsSetting={documentsSetting}
|
||||||
<Loader size="sm" color="edr-green" />
|
documentFiles={documentFiles}
|
||||||
</Group>
|
uploadedDocumentKeys={uploadedDocumentKeys}
|
||||||
) : !documentsSetting ? (
|
documentFieldErrors={documentFieldErrors}
|
||||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
onDocumentFilesChange={handleDocumentFilesChange}
|
||||||
No document requirements found for your account type.
|
roleProfiles={roleProfiles}
|
||||||
</Text>
|
licenseFiles={licenseFiles}
|
||||||
) : (
|
licenseFieldErrors={licenseFieldErrors}
|
||||||
<SmartFileInput
|
onLicenseFilesChange={handleLicenseFilesChange}
|
||||||
file={documentsSetting}
|
/>
|
||||||
value={documentFiles}
|
|
||||||
uploadedKeys={uploadedDocumentKeys}
|
|
||||||
errors={documentFieldErrors}
|
|
||||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
|
||||||
onChange={handleDocumentFilesChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<RoleLicenseStep
|
|
||||||
profiles={roleProfiles ?? []}
|
|
||||||
value={licenseFiles ?? {}}
|
|
||||||
onChange={handleLicenseFilesChange}
|
|
||||||
errors={licenseFieldErrors}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{saveError && (
|
{saveError && (
|
||||||
|
|||||||
@@ -1,328 +0,0 @@
|
|||||||
import { Box, Button, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Building2,
|
|
||||||
CheckCircle2,
|
|
||||||
ChevronLeft,
|
|
||||||
UploadCloud,
|
|
||||||
UserRound,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import type { AuthUser } from "@/types/auth";
|
|
||||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
import { SmartFileInput } from "@edr/ui-common";
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
|
|
||||||
type DjiboutiStep = "company" | "representative" | "documents" | "confirm";
|
|
||||||
|
|
||||||
const djiboutiSchema = z.object({
|
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
|
||||||
companyPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
companyLocation: z.string().min(1, "Location / Country is required"),
|
|
||||||
companyAddress: z.string().min(1, "Address is required"),
|
|
||||||
repName: z.string().min(1, "Representative name is required"),
|
|
||||||
repEmail: z.string().email("Invalid representative email"),
|
|
||||||
repPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Representative phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof djiboutiSchema>;
|
|
||||||
|
|
||||||
const stepFields: Record<DjiboutiStep, (keyof FormData)[]> = {
|
|
||||||
company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress"],
|
|
||||||
representative: ["repName", "repEmail", "repPhone"],
|
|
||||||
documents: [],
|
|
||||||
confirm: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload {
|
|
||||||
return {
|
|
||||||
companyName: data.companyName,
|
|
||||||
companyEmail: data.companyEmail,
|
|
||||||
companyPhone: data.companyPhone,
|
|
||||||
companyLocation: data.companyLocation,
|
|
||||||
companyAddress: data.companyAddress,
|
|
||||||
tin: "",
|
|
||||||
vatNumber: "",
|
|
||||||
fanNumber: "",
|
|
||||||
attributes: {
|
|
||||||
repName: data.repName,
|
|
||||||
repEmail: data.repEmail,
|
|
||||||
repPhone: data.repPhone,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DjiboutiAgentForm({
|
|
||||||
documentSettingCode,
|
|
||||||
documentFiles: controlledFiles,
|
|
||||||
onDocumentFilesChange,
|
|
||||||
user,
|
|
||||||
onSubmit,
|
|
||||||
isPending,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
documentSettingCode: string;
|
|
||||||
documentFiles?: Record<string, File | File[] | null>;
|
|
||||||
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => void;
|
|
||||||
user: AuthUser;
|
|
||||||
onSubmit: (data: CreateCompanyPayload) => void;
|
|
||||||
isPending: boolean;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const [step, setStep] = useState<DjiboutiStep>("company");
|
|
||||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
|
||||||
const documentFiles = controlledFiles ?? internalFiles;
|
|
||||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
|
||||||
|
|
||||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
|
||||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm<FormData>({
|
|
||||||
resolver: zodResolver(djiboutiSchema),
|
|
||||||
defaultValues: {
|
|
||||||
companyName: "", companyEmail: "", companyPhone: "",
|
|
||||||
companyLocation: "", companyAddress: "", repName: "", repEmail: "", repPhone: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const formValues = watch();
|
|
||||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
|
||||||
const totalSteps = 4;
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
|
||||||
if (step === "representative") { setStep("documents"); return; }
|
|
||||||
if (step === "documents") { setStep("confirm"); return; }
|
|
||||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
|
||||||
const isValid = await trigger(stepFields[step]);
|
|
||||||
if (!isValid) return;
|
|
||||||
setStep("representative");
|
|
||||||
};
|
|
||||||
|
|
||||||
const skipDocuments = () => setStep("confirm");
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
if (step === "company") onBack();
|
|
||||||
else if (step === "representative") setStep("company");
|
|
||||||
else if (step === "documents") setStep("representative");
|
|
||||||
else setStep("documents");
|
|
||||||
};
|
|
||||||
|
|
||||||
const STEPS: { key: DjiboutiStep; icon: React.ReactNode }[] = [
|
|
||||||
{ key: "company", icon: <Building2 size={18} /> },
|
|
||||||
{ key: "representative", icon: <UserRound size={18} /> },
|
|
||||||
{ key: "documents", icon: <UploadCloud size={18} /> },
|
|
||||||
{ key: "confirm", icon: <CheckCircle2 size={18} /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEP_LABELS: Record<DjiboutiStep, string> = {
|
|
||||||
company: `Step 1 of ${totalSteps} — Company Information`,
|
|
||||||
representative: `Step 2 of ${totalSteps} — Representative Details`,
|
|
||||||
documents: `Step 3 of ${totalSteps} — Upload Documents (Optional)`,
|
|
||||||
confirm: `Step 4 of ${totalSteps} — Review & Confirm`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepOrder: DjiboutiStep[] = ["company", "representative", "documents", "confirm"];
|
|
||||||
const currentIdx = stepOrder.indexOf(step);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Box mb="xl">
|
|
||||||
<Button
|
|
||||||
variant="subtle"
|
|
||||||
c="edr-muted"
|
|
||||||
size="sm"
|
|
||||||
mb="sm"
|
|
||||||
leftSection={<ChevronLeft size={16} />}
|
|
||||||
onClick={prevStep}
|
|
||||||
>
|
|
||||||
Change account type
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
|
||||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
|
||||||
{STEPS.map(({ key, icon }, i) => {
|
|
||||||
const done = i < currentIdx;
|
|
||||||
const active = i === currentIdx;
|
|
||||||
return done || active ? (
|
|
||||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
|
||||||
{done ? <CheckCircle2 size={18} /> : icon}
|
|
||||||
</ThemeIcon>
|
|
||||||
) : (
|
|
||||||
<Box
|
|
||||||
key={key}
|
|
||||||
w={40}
|
|
||||||
h={40}
|
|
||||||
c="edr-slate"
|
|
||||||
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
<Text size="sm" c="edr-muted" ta="center" mt="sm">
|
|
||||||
{STEP_LABELS[step]}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<form onSubmit={(e) => e.preventDefault()}>
|
|
||||||
<Stack gap="md">
|
|
||||||
{step === "company" && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label="Company Name"
|
|
||||||
placeholder="Djibouti Logistics SARL"
|
|
||||||
error={errors.companyName?.message}
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Company Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="info@djib-logistics.dj"
|
|
||||||
error={errors.companyEmail?.message}
|
|
||||||
{...register("companyEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Location / Country"
|
|
||||||
placeholder="Djibouti City, Djibouti"
|
|
||||||
error={errors.companyLocation?.message}
|
|
||||||
{...register("companyLocation")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Address"
|
|
||||||
placeholder="Boulevard de la République"
|
|
||||||
error={errors.companyAddress?.message}
|
|
||||||
{...register("companyAddress")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "representative" && (
|
|
||||||
<>
|
|
||||||
<Text size="sm" c="edr-muted">
|
|
||||||
Provide the company representative details for this account.
|
|
||||||
</Text>
|
|
||||||
<TextInput
|
|
||||||
label="Representative Name"
|
|
||||||
placeholder="Ahmed Hassan"
|
|
||||||
error={errors.repName?.message}
|
|
||||||
{...register("repName")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="Representative Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="ahmed@company.dj"
|
|
||||||
error={errors.repEmail?.message}
|
|
||||||
{...register("repEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="repPhone"
|
|
||||||
label="Representative Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "documents" && (
|
|
||||||
<>
|
|
||||||
{loadingDocuments ? (
|
|
||||||
<Group justify="center" py="xl">
|
|
||||||
<Loader size="sm" color="edr-green" />
|
|
||||||
</Group>
|
|
||||||
) : !uploadSetting ? (
|
|
||||||
<Text size="sm" c="edr-muted" ta="center" py="md">
|
|
||||||
No document requirements found for your account type.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === "confirm" && (
|
|
||||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
|
||||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
|
||||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
|
||||||
Confirm the company details below before saving.
|
|
||||||
</Text>
|
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
|
||||||
<ReviewRow label="Company name" value={formValues.companyName} />
|
|
||||||
<ReviewRow label="Company email" value={formValues.companyEmail} />
|
|
||||||
<ReviewRow label="Company phone" value={formValues.companyPhone} />
|
|
||||||
<ReviewRow label="Location" value={formValues.companyLocation} />
|
|
||||||
<ReviewRow label="Address" value={formValues.companyAddress} />
|
|
||||||
<ReviewRow label="Rep. name" value={formValues.repName} />
|
|
||||||
<ReviewRow label="Rep. email" value={formValues.repEmail} />
|
|
||||||
<ReviewRow label="Rep. phone" value={formValues.repPhone} />
|
|
||||||
</SimpleGrid>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Group justify="space-between" pt="xs">
|
|
||||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
|
||||||
{step === "company" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
|
||||||
</Button>
|
|
||||||
<Group gap="sm">
|
|
||||||
{step === "documents" && (
|
|
||||||
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
|
|
||||||
Skip for now
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
|
||||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
|
||||||
loading={isPending}
|
|
||||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
|
||||||
>
|
|
||||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
|
||||||
return (
|
|
||||||
<Box p={12} className="rounded-xl bg-edr-bg">
|
|
||||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
|
||||||
{value?.trim() ? value : "Not provided"}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +30,13 @@ import type { CreateCompanyPayload } from "@/services/companies.service";
|
|||||||
import { SmartFileInput } from "@edr/ui-common";
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
import { api } from "@/services/api";
|
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";
|
type TransporterStep = "vehicle" | "documents" | "confirm";
|
||||||
|
|
||||||
@@ -36,7 +54,10 @@ const transporterSchema = z
|
|||||||
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
|
.regex(/^\d{4}$/, "Enter a valid year (e.g. 2023)"),
|
||||||
})
|
})
|
||||||
.superRefine((data, ctx) => {
|
.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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ["plateNumber2"],
|
path: ["plateNumber2"],
|
||||||
@@ -50,8 +71,6 @@ type FormData = z.infer<typeof transporterSchema>;
|
|||||||
function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
|
function buildPayload(data: FormData, user: AuthUser): CreateCompanyPayload {
|
||||||
return {
|
return {
|
||||||
companyName: user.name?.en ?? "",
|
companyName: user.name?.en ?? "",
|
||||||
companyEmail: user.email,
|
|
||||||
companyPhone: user.phoneNumber,
|
|
||||||
companyLocation: "",
|
companyLocation: "",
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
tin: data.tinNumber,
|
tin: data.tinNumber,
|
||||||
@@ -85,18 +104,36 @@ export default function TransporterForm({
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [step, setStep] = useState<TransporterStep>("vehicle");
|
const [step, setStep] = useState<TransporterStep>("vehicle");
|
||||||
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
|
const [internalFiles, setInternalFiles] = useState<
|
||||||
|
Record<string, File | File[] | null>
|
||||||
|
>({});
|
||||||
const documentFiles = controlledFiles ?? internalFiles;
|
const documentFiles = controlledFiles ?? internalFiles;
|
||||||
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;
|
||||||
|
|
||||||
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
const { data: uploadSetting, isLoading: loadingDocuments } = useQuery(
|
||||||
api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }),
|
api.fileUploadSettings.getByCode.queryOptions({
|
||||||
|
input: { code: documentSettingCode },
|
||||||
|
refetchOnMount: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { register, handleSubmit, trigger, watch, control, formState: { errors } } = useForm<FormData>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
trigger,
|
||||||
|
watch,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(transporterSchema),
|
resolver: zodResolver(transporterSchema),
|
||||||
defaultValues: {
|
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 totalSteps = 3;
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step === "documents") { setStep("confirm"); return; }
|
if (step === "documents") {
|
||||||
if (step === "confirm") { handleSubmit((data) => onSubmit(buildPayload(data, user)))(); return; }
|
setStep("confirm");
|
||||||
const fields: (keyof FormData)[] = ["tinNumber", "fanNumber", "truckType", "plateNumber", "vehicleModel", "yearOfManufacturing"];
|
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);
|
const isValid = await trigger(fields);
|
||||||
if (!isValid) return;
|
if (!isValid) return;
|
||||||
setStep("documents");
|
setStep("documents");
|
||||||
@@ -152,13 +202,24 @@ export default function TransporterForm({
|
|||||||
Change account type
|
Change account type
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
align="center"
|
||||||
|
className="relative max-w-lg mx-auto px-2"
|
||||||
|
>
|
||||||
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
|
||||||
{STEPS.map(({ key, icon }, i) => {
|
{STEPS.map(({ key, icon }, i) => {
|
||||||
const done = i < currentIdx;
|
const done = i < currentIdx;
|
||||||
const active = i === currentIdx;
|
const active = i === currentIdx;
|
||||||
return done || active ? (
|
return done || active ? (
|
||||||
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
|
<ThemeIcon
|
||||||
|
key={key}
|
||||||
|
size={40}
|
||||||
|
radius="xl"
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
className="relative z-10"
|
||||||
|
>
|
||||||
{done ? <CheckCircle2 size={18} /> : icon}
|
{done ? <CheckCircle2 size={18} /> : icon}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
) : (
|
) : (
|
||||||
@@ -203,7 +264,9 @@ export default function TransporterForm({
|
|||||||
|
|
||||||
<Divider color="edr-border" />
|
<Divider color="edr-border" />
|
||||||
|
|
||||||
<Text fw={600} size="sm" c="edr-text">Vehicle / Truck Information</Text>
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
Vehicle / Truck Information
|
||||||
|
</Text>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
name="truckType"
|
name="truckType"
|
||||||
@@ -276,14 +339,23 @@ export default function TransporterForm({
|
|||||||
No document requirements found for your account type.
|
No document requirements found for your account type.
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
|
<SmartFileInput
|
||||||
|
file={uploadSetting}
|
||||||
|
value={documentFiles}
|
||||||
|
onChange={setDocumentFiles}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "confirm" && (
|
{step === "confirm" && (
|
||||||
<Box p={16} className="rounded-2xl border border-edr-border bg-edr-card">
|
<Box
|
||||||
<Text fw={600} c="edr-text">Review your registration</Text>
|
p={16}
|
||||||
|
className="rounded-2xl border border-edr-border bg-edr-card"
|
||||||
|
>
|
||||||
|
<Text fw={600} c="edr-text">
|
||||||
|
Review your registration
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
<Text size="sm" c="edr-muted" mt={4} mb="md">
|
||||||
Confirm the details below before saving.
|
Confirm the details below before saving.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -291,32 +363,73 @@ export default function TransporterForm({
|
|||||||
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
|
<ReviewRow label="TIN Number" value={formValues.tinNumber} />
|
||||||
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
|
<ReviewRow label="FAN Number" value={formValues.fanNumber} />
|
||||||
<ReviewRow label="Truck Type" value={formValues.truckType} />
|
<ReviewRow label="Truck Type" value={formValues.truckType} />
|
||||||
<ReviewRow label="Plate Number" value={formValues.plateNumber} />
|
<ReviewRow
|
||||||
{formValues.plateNumber2 && <ReviewRow label="Plate (Trailer)" value={formValues.plateNumber2} />}
|
label="Plate Number"
|
||||||
<ReviewRow label="Vehicle Model" value={formValues.vehicleModel} />
|
value={formValues.plateNumber}
|
||||||
<ReviewRow label="Year" value={formValues.yearOfManufacturing} />
|
/>
|
||||||
|
{formValues.plateNumber2 && (
|
||||||
|
<ReviewRow
|
||||||
|
label="Plate (Trailer)"
|
||||||
|
value={formValues.plateNumber2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ReviewRow
|
||||||
|
label="Vehicle Model"
|
||||||
|
value={formValues.vehicleModel}
|
||||||
|
/>
|
||||||
|
<ReviewRow
|
||||||
|
label="Year"
|
||||||
|
value={formValues.yearOfManufacturing}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="space-between" pt="xs">
|
<Group justify="space-between" pt="xs">
|
||||||
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
|
<Button
|
||||||
{step === "vehicle" ? "Change Type" : step === "confirm" ? "Back to Documents" : "Back"}
|
variant="default"
|
||||||
|
onClick={prevStep}
|
||||||
|
leftSection={<ArrowLeft size={16} />}
|
||||||
|
>
|
||||||
|
{step === "vehicle"
|
||||||
|
? "Change Type"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Back to Documents"
|
||||||
|
: "Back"}
|
||||||
</Button>
|
</Button>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{step === "documents" && (
|
{step === "documents" && (
|
||||||
<Button variant="default" onClick={skipDocuments} disabled={isPending}>
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={skipDocuments}
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
Skip for now
|
Skip for now
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={step === "confirm" ? handleSubmit((data) => onSubmit(buildPayload(data, user))) : nextStep}
|
onClick={
|
||||||
disabled={isPending || (step === "documents" && !hasDocuments && loadingDocuments)}
|
step === "confirm"
|
||||||
|
? handleSubmit((data) => onSubmit(buildPayload(data, user)))
|
||||||
|
: nextStep
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isPending ||
|
||||||
|
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||||
|
}
|
||||||
loading={isPending}
|
loading={isPending}
|
||||||
rightSection={!isPending && step !== "confirm" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
|
rightSection={
|
||||||
|
!isPending && step !== "confirm" && step !== "documents" ? (
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{step === "documents" ? "Continue" : step === "confirm" ? "Submit Registration" : "Next Step"}
|
{step === "documents"
|
||||||
|
? "Continue"
|
||||||
|
: step === "confirm"
|
||||||
|
? "Submit Registration"
|
||||||
|
: "Next Step"}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -329,10 +442,20 @@ export default function TransporterForm({
|
|||||||
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
function ReviewRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Box p={12} className="rounded-xl border border-edr-border bg-edr-card">
|
<Box p={12} className="rounded-xl border border-edr-border bg-edr-card">
|
||||||
<Text size="xs" fw={600} c="edr-muted" className="uppercase tracking-wide">
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c="edr-muted"
|
||||||
|
className="uppercase tracking-wide"
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500} c={value?.trim() ? "edr-text" : "edr-muted"} mt={4}>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
c={value?.trim() ? "edr-text" : "edr-muted"}
|
||||||
|
mt={4}
|
||||||
|
>
|
||||||
{value?.trim() ? value : "Not provided"}
|
{value?.trim() ? value : "Not provided"}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
|||||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
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.
|
* 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
|
* 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.
|
* the form a blank it has no input to fix.
|
||||||
*/
|
*/
|
||||||
export const firstPresent = (...values: (string | null | undefined)[]): string =>
|
export const firstPresent = (
|
||||||
values.find((v) => v && v.trim())?.trim() ?? "";
|
...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.
|
* First candidate that is actually a usable phone number, normalized to E.164.
|
||||||
@@ -87,8 +92,6 @@ export function buildPayload(
|
|||||||
): CreateCompanyPayload {
|
): CreateCompanyPayload {
|
||||||
return {
|
return {
|
||||||
companyName: data.companyName,
|
companyName: data.companyName,
|
||||||
companyEmail: data.companyEmail,
|
|
||||||
companyPhone: data.companyPhone,
|
|
||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
tin: data.tinNumber,
|
tin: data.tinNumber,
|
||||||
vatNumber: data.vatNumber,
|
vatNumber: data.vatNumber,
|
||||||
@@ -129,8 +132,6 @@ export function stepPayload(
|
|||||||
}
|
}
|
||||||
if (dirty.tinNumber) etrade.tin = d.tinNumber;
|
if (dirty.tinNumber) etrade.tin = d.tinNumber;
|
||||||
return {
|
return {
|
||||||
companyEmail: d.companyEmail,
|
|
||||||
companyPhone: d.companyPhone,
|
|
||||||
companyAddress: d.companyAddress,
|
companyAddress: d.companyAddress,
|
||||||
vatNumber: d.vatNumber,
|
vatNumber: d.vatNumber,
|
||||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||||
@@ -168,8 +169,6 @@ export function toFormValues(p: ProfileResponse): FormData {
|
|||||||
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : "";
|
||||||
return {
|
return {
|
||||||
companyName: p.companyName ?? "",
|
companyName: p.companyName ?? "",
|
||||||
companyEmail: p.companyEmail ?? "",
|
|
||||||
companyPhone: p.companyPhone ?? "",
|
|
||||||
companyAddress: p.companyAddress ?? "",
|
companyAddress: p.companyAddress ?? "",
|
||||||
etradePhone: p.etradePhone ?? "",
|
etradePhone: p.etradePhone ?? "",
|
||||||
tinNumber: tin,
|
tinNumber: tin,
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ import type { CompanyIdentityState } from "@/services/verifayda.service";
|
|||||||
const values = (over: Partial<FormData> = {}): FormData =>
|
const values = (over: Partial<FormData> = {}): FormData =>
|
||||||
({
|
({
|
||||||
companyName: "Acme PLC",
|
companyName: "Acme PLC",
|
||||||
companyEmail: "acme@example.com",
|
|
||||||
companyPhone: "+251911223344",
|
|
||||||
companyAddress: "1, Bole, Bole, Addis Ababa",
|
companyAddress: "1, Bole, Bole, Addis Ababa",
|
||||||
etradePhone: "+251911223344",
|
etradePhone: "+251911223344",
|
||||||
tinNumber: "0012345678",
|
tinNumber: "0012345678",
|
||||||
@@ -56,7 +54,9 @@ const errorFor = (data: FormData, field: keyof FormData) => {
|
|||||||
|
|
||||||
describe("VAT number", () => {
|
describe("VAT number", () => {
|
||||||
it("accepts exactly ten digits", () => {
|
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.
|
// `.length(10)` used to pass this, so a ten-letter string reached the API.
|
||||||
@@ -87,9 +87,6 @@ describe("stepFields", () => {
|
|||||||
// to nothing on screen.
|
// to nothing on screen.
|
||||||
it("never gates the company step on a derived or read-only field", () => {
|
it("never gates the company step on a derived or read-only field", () => {
|
||||||
const unreachable = [
|
const unreachable = [
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyAddress",
|
|
||||||
"etradePhone",
|
"etradePhone",
|
||||||
"licenceNumber",
|
"licenceNumber",
|
||||||
"statusDescription",
|
"statusDescription",
|
||||||
@@ -98,9 +95,9 @@ describe("stepFields", () => {
|
|||||||
"renewalDate",
|
"renewalDate",
|
||||||
"renewedTo",
|
"renewedTo",
|
||||||
];
|
];
|
||||||
expect(
|
expect(stepFields.company.filter((f) => unreachable.includes(f))).toEqual(
|
||||||
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
|
// "+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.
|
// with no input, and the API rejected the whole step.
|
||||||
it("skips an eTrade number that cannot make a valid E.164", () => {
|
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", () => {
|
it("normalizes a local number it can use", () => {
|
||||||
@@ -165,9 +164,31 @@ describe("normalizeIdentityPhones", () => {
|
|||||||
const identity = {
|
const identity = {
|
||||||
faydaRequired: true,
|
faydaRequired: true,
|
||||||
passportRequired: false,
|
passportRequired: false,
|
||||||
owner: { verified: true, name: "A", phone: "0911223344", email: null, address: null, verifiedAt: null, passportNumber: null },
|
owner: {
|
||||||
poa: { verified: false, name: null, phone: null, email: null, address: null, verifiedAt: null },
|
verified: true,
|
||||||
gm: { verified: false, name: null, phone: "251911223344", email: null, address: null, verifiedAt: null },
|
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,
|
gmSameAsOwner: false,
|
||||||
complete: false,
|
complete: false,
|
||||||
} as CompanyIdentityState;
|
} as CompanyIdentityState;
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ export type CompanyStep =
|
|||||||
|
|
||||||
export const onboardingSchema = z.object({
|
export const onboardingSchema = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
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
|
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||||
// standalone input — the granular fields live in the registration section.
|
// standalone input — the granular fields live in the registration section.
|
||||||
companyAddress: z.string().optional(),
|
companyAddress: z.string().optional(),
|
||||||
@@ -109,7 +104,6 @@ export type FormData = z.infer<typeof onboardingSchema>;
|
|||||||
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
/** fileKey of the delegation letter uploaded on the Power of Attorney step. */
|
||||||
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
export const POA_DELEGATION_FILE_KEY = "poa_delegation_letter";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The PoA's identifying fields are never typed — they come from the Fayda
|
* The PoA's identifying fields are never typed — they come from the Fayda
|
||||||
* verification, whatever the company's nationality — so nothing here requires
|
* verification, whatever the company's nationality — so nothing here requires
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { SimpleGrid, Stack, TextInput } from "@mantine/core";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import type { CompanyRegistrationData } from "@edr/types";
|
||||||
|
import { ControlledPhoneField } from "@/components/PhoneField";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import ETradeInfo, {
|
||||||
|
type ETradeStatus,
|
||||||
|
} from "@/components/onboarding/ETradeInfo";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
import type { FormData } from "../schema";
|
||||||
|
import ETradeCompanyCard from "../ETradeCompanyCard";
|
||||||
|
import StepSection from "../StepSection";
|
||||||
|
|
||||||
|
export interface CompanyInfoStepProps {
|
||||||
|
form: UseFormReturn<FormData>;
|
||||||
|
/** Fayda verification state, phone-normalized by the parent. */
|
||||||
|
identity?: CompanyIdentityState;
|
||||||
|
/** True when Fayda (not a passport) is what this company must prove with. */
|
||||||
|
verifiedIdentity: boolean;
|
||||||
|
tinStatus: ETradeStatus;
|
||||||
|
tinVerified: boolean;
|
||||||
|
/** Registration fields are already populated (a lookup passed, now or earlier). */
|
||||||
|
hasRegistrationDetails: boolean;
|
||||||
|
onETradeDataLoaded: (data: CompanyRegistrationData) => void;
|
||||||
|
onETradeStatusChange: (status: ETradeStatus) => void;
|
||||||
|
onETradeReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CompanyInfoStep({
|
||||||
|
form,
|
||||||
|
identity,
|
||||||
|
verifiedIdentity,
|
||||||
|
tinStatus,
|
||||||
|
tinVerified,
|
||||||
|
hasRegistrationDetails,
|
||||||
|
onETradeDataLoaded,
|
||||||
|
onETradeStatusChange,
|
||||||
|
onETradeReset,
|
||||||
|
}: CompanyInfoStepProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<StepSection
|
||||||
|
index={1}
|
||||||
|
title="VAT number"
|
||||||
|
status={
|
||||||
|
watch("vatNumber")?.length === 10 && !errors.vatNumber
|
||||||
|
? "done"
|
||||||
|
: "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
aria-label="VAT Number"
|
||||||
|
placeholder="0012345678"
|
||||||
|
maxLength={10}
|
||||||
|
error={errors.vatNumber?.message}
|
||||||
|
{...register("vatNumber")}
|
||||||
|
/>
|
||||||
|
</StepSection>
|
||||||
|
|
||||||
|
<StepSection
|
||||||
|
index={2}
|
||||||
|
title="Owner identity"
|
||||||
|
subtitle={
|
||||||
|
!identity?.owner.verified && !verifiedIdentity
|
||||||
|
? "Provide the company owner's passport number."
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
status={
|
||||||
|
verifiedIdentity
|
||||||
|
? identity?.owner.verified
|
||||||
|
? "done"
|
||||||
|
: identity?.faydaRequired
|
||||||
|
? "blocked"
|
||||||
|
: "todo"
|
||||||
|
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
||||||
|
? "done"
|
||||||
|
: identity?.passportRequired
|
||||||
|
? "blocked"
|
||||||
|
: "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{identity && (
|
||||||
|
<>
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="owner"
|
||||||
|
title="Owner"
|
||||||
|
state={identity.owner}
|
||||||
|
required={identity.faydaRequired}
|
||||||
|
/>
|
||||||
|
{identity.passportRequired && (
|
||||||
|
<TextInput
|
||||||
|
label="Owner Passport Number"
|
||||||
|
placeholder="P1234567"
|
||||||
|
error={errors.ownerPassportNumber?.message}
|
||||||
|
{...register("ownerPassportNumber")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</StepSection>
|
||||||
|
|
||||||
|
<StepSection
|
||||||
|
index={3}
|
||||||
|
title="Company TIN"
|
||||||
|
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||||
|
status={
|
||||||
|
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ETradeInfo
|
||||||
|
tin={watch("tinNumber")}
|
||||||
|
register={register("tinNumber")}
|
||||||
|
error={errors.tinNumber?.message}
|
||||||
|
onDataLoaded={onETradeDataLoaded}
|
||||||
|
onStatusChange={onETradeStatusChange}
|
||||||
|
onReset={onETradeReset}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
|
/>
|
||||||
|
{tinVerified && (
|
||||||
|
<ETradeCompanyCard
|
||||||
|
tin={watch("tinNumber")}
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StepSection>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import { ControlledPhoneField } from "@/components/PhoneField";
|
||||||
|
|
||||||
|
import type { FormData } from "../schema";
|
||||||
|
import { LinkCheckboxCard } from "../LinkCheckboxCard";
|
||||||
|
|
||||||
|
export interface ContactStepProps {
|
||||||
|
form: UseFormReturn<FormData>;
|
||||||
|
/**
|
||||||
|
* The GM's name from whichever source established them (verification or form)
|
||||||
|
* — the "same as GM" card only makes sense once there is a GM.
|
||||||
|
*/
|
||||||
|
gmName?: string;
|
||||||
|
contactSameAsGm: boolean;
|
||||||
|
onToggleContactSameAsGm: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactStep({
|
||||||
|
form,
|
||||||
|
gmName,
|
||||||
|
contactSameAsGm,
|
||||||
|
onToggleContactSameAsGm,
|
||||||
|
}: ContactStepProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
Contact Person
|
||||||
|
</Text>
|
||||||
|
{/* `gmName`, not the raw form field: a Fayda-verified GM never
|
||||||
|
fills `generalManagerName`, so gating on it hid this card from
|
||||||
|
every Ethiopian company — the majority case. */}
|
||||||
|
{gmName && (
|
||||||
|
<LinkCheckboxCard
|
||||||
|
checked={contactSameAsGm}
|
||||||
|
onToggle={onToggleContactSameAsGm}
|
||||||
|
title="Same as General Manager"
|
||||||
|
description="Reuse the general manager's name, email and phone. Uncheck to enter different details."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Jane Smith"
|
||||||
|
error={errors.contactPersonName?.message}
|
||||||
|
{...register("contactPersonName")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Position (Optional)"
|
||||||
|
placeholder="Operations Lead"
|
||||||
|
error={errors.contactPersonPosition?.message}
|
||||||
|
{...register("contactPersonPosition")}
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Email (Optional)"
|
||||||
|
type="email"
|
||||||
|
placeholder="contact@company.com"
|
||||||
|
error={errors.contactPersonEmail?.message}
|
||||||
|
{...register("contactPersonEmail")}
|
||||||
|
/>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="contactPersonPhone"
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Group, Loader, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
|
import RoleLicenseStep, {
|
||||||
|
type RoleLicenseProfile,
|
||||||
|
} from "@/components/onboarding/RoleLicenseStep";
|
||||||
|
import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||||
|
|
||||||
|
export interface DocumentsStepProps {
|
||||||
|
loadingDocuments: boolean;
|
||||||
|
/** Nationality document set, minus the PoA delegation letter (its own step). */
|
||||||
|
documentsSetting?: FileUploadSetting;
|
||||||
|
documentFiles: Record<string, File | File[] | null>;
|
||||||
|
uploadedDocumentKeys?: string[];
|
||||||
|
documentFieldErrors: Record<string, string>;
|
||||||
|
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
|
||||||
|
roleProfiles?: RoleLicenseProfile[];
|
||||||
|
licenseFiles?: Record<string, File[]>;
|
||||||
|
licenseFieldErrors: Record<string, string>;
|
||||||
|
onLicenseFilesChange: (next: Record<string, File[]>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DocumentsStep({
|
||||||
|
loadingDocuments,
|
||||||
|
documentsSetting,
|
||||||
|
documentFiles,
|
||||||
|
uploadedDocumentKeys,
|
||||||
|
documentFieldErrors,
|
||||||
|
onDocumentFilesChange,
|
||||||
|
roleProfiles,
|
||||||
|
licenseFiles,
|
||||||
|
licenseFieldErrors,
|
||||||
|
onLicenseFilesChange,
|
||||||
|
}: DocumentsStepProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{loadingDocuments ? (
|
||||||
|
<Group justify="center" py="xl">
|
||||||
|
<Loader size="sm" color="edr-green" />
|
||||||
|
</Group>
|
||||||
|
) : !documentsSetting ? (
|
||||||
|
<Text size="sm" c="edr-muted" ta="center" py="md">
|
||||||
|
No document requirements found for your account type.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<SmartFileInput
|
||||||
|
file={documentsSetting}
|
||||||
|
value={documentFiles}
|
||||||
|
uploadedKeys={uploadedDocumentKeys}
|
||||||
|
errors={documentFieldErrors}
|
||||||
|
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||||
|
onChange={onDocumentFilesChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<RoleLicenseStep
|
||||||
|
profiles={roleProfiles ?? []}
|
||||||
|
value={licenseFiles ?? {}}
|
||||||
|
onChange={onLicenseFilesChange}
|
||||||
|
errors={licenseFieldErrors}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import { ControlledPhoneField } from "@/components/PhoneField";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
import type { FormData } from "../schema";
|
||||||
|
import { LinkCheckboxCard } from "../LinkCheckboxCard";
|
||||||
|
|
||||||
|
export interface PersonnelStepProps {
|
||||||
|
form: UseFormReturn<FormData>;
|
||||||
|
identity?: CompanyIdentityState;
|
||||||
|
/** eTrade-registered owner, once a TIN lookup has succeeded. */
|
||||||
|
etradeOwner: { name: string; phone: string } | null;
|
||||||
|
gmSameAsOwner: boolean;
|
||||||
|
onToggleGmSameAsOwner: (checked: boolean) => void;
|
||||||
|
/** A server-side "same as owner" declaration is in flight. */
|
||||||
|
gmLinkPending: boolean;
|
||||||
|
gmVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PersonnelStep({
|
||||||
|
form,
|
||||||
|
identity,
|
||||||
|
etradeOwner,
|
||||||
|
gmSameAsOwner,
|
||||||
|
onToggleGmSameAsOwner,
|
||||||
|
gmLinkPending,
|
||||||
|
gmVerified,
|
||||||
|
}: PersonnelStepProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text fw={600} size="sm" c="edr-text">
|
||||||
|
General Manager
|
||||||
|
</Text>
|
||||||
|
{/* The GM is very often the owner. Where the owner is
|
||||||
|
Fayda-verified this reuses that proven identity outright
|
||||||
|
rather than making the same human verify twice; where the
|
||||||
|
owner is backed by a typed passport there is nothing proven
|
||||||
|
to copy, so it stays a local prefill. */}
|
||||||
|
<LinkCheckboxCard
|
||||||
|
checked={gmSameAsOwner}
|
||||||
|
onToggle={onToggleGmSameAsOwner}
|
||||||
|
title={
|
||||||
|
identity?.owner.verified
|
||||||
|
? "Same as verified owner"
|
||||||
|
: "Same as business owner"
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
identity?.owner.verified
|
||||||
|
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||||
|
: etradeOwner
|
||||||
|
? "Reuse the eTrade-registered owner's name, plus the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
|
: "Reuse your account's name and the company email and phone as you entered them. Uncheck to enter different details."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Verifying a second person is only meaningful when the GM is
|
||||||
|
someone other than the owner. */}
|
||||||
|
{!gmSameAsOwner && identity && (
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="gm"
|
||||||
|
title="General Manager"
|
||||||
|
state={identity.gm}
|
||||||
|
required={identity.faydaRequired}
|
||||||
|
disabled={gmLinkPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Typed details survive only where Fayda cannot be required —
|
||||||
|
a foreign company's manager may hold no Fayda ID. Once
|
||||||
|
verified the API owns these fields, so they go away. */}
|
||||||
|
{!gmSameAsOwner && !gmVerified && !identity?.faydaRequired && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Abebe Bikila"
|
||||||
|
error={errors.generalManagerName?.message}
|
||||||
|
{...register("generalManagerName")}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="gm@company.com"
|
||||||
|
error={errors.generalManagerEmail?.message}
|
||||||
|
{...register("generalManagerEmail")}
|
||||||
|
/>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="generalManagerPhone"
|
||||||
|
label="Phone"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Divider, SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||||
|
import type { UseFormReturn } from "react-hook-form";
|
||||||
|
|
||||||
|
import { SmartFileInput } from "@edr/ui-common";
|
||||||
|
import { ControlledPhoneField } from "@/components/PhoneField";
|
||||||
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
|
import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||||
|
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||||
|
|
||||||
|
import type { FormData } from "../schema";
|
||||||
|
|
||||||
|
export interface PoaStepProps {
|
||||||
|
form: UseFormReturn<FormData>;
|
||||||
|
identity?: CompanyIdentityState;
|
||||||
|
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
|
||||||
|
requirePoa: boolean;
|
||||||
|
/** The DARS delegation paper is owed (a PoA exists, or the company forwards). */
|
||||||
|
delegationRequired: boolean;
|
||||||
|
/** Single-field upload setting carrying just the delegation letter. */
|
||||||
|
poaDocumentSetting?: FileUploadSetting;
|
||||||
|
documentFiles: Record<string, File | File[] | null>;
|
||||||
|
uploadedDocumentKeys?: string[];
|
||||||
|
documentFieldErrors: Record<string, string>;
|
||||||
|
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PoaStep({
|
||||||
|
form,
|
||||||
|
identity,
|
||||||
|
requirePoa,
|
||||||
|
delegationRequired,
|
||||||
|
poaDocumentSetting,
|
||||||
|
documentFiles,
|
||||||
|
uploadedDocumentKeys,
|
||||||
|
documentFieldErrors,
|
||||||
|
onDocumentFilesChange,
|
||||||
|
}: PoaStepProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="edr-muted">
|
||||||
|
{requirePoa
|
||||||
|
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||||
|
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}
|
||||||
|
</Text>
|
||||||
|
{/* A representative acts for the company inside Ethiopia
|
||||||
|
whoever owns it, so the PoA is proven with Fayda regardless of
|
||||||
|
nationality — their name, email, phone and address all come
|
||||||
|
from the verification and are never typed here. */}
|
||||||
|
{identity && (
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="poa"
|
||||||
|
title="Power of Attorney"
|
||||||
|
state={identity.poa}
|
||||||
|
required={requirePoa}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* A verified representative's details come from the Fayda claim
|
||||||
|
and are shown on the panel above. Where Fayda cannot be
|
||||||
|
required — a foreign company whose representative may hold no
|
||||||
|
Fayda ID — they are typed here instead. They have to be: the
|
||||||
|
API refuses to save a freight forwarder's PoA without a name,
|
||||||
|
email and phone (`REQUIRED_POA_FIELDS`), and before this the
|
||||||
|
step rendered no input for any of them, so the customer was
|
||||||
|
told to "add the poa name, poa email, poa phone" with nowhere
|
||||||
|
to add them. */}
|
||||||
|
{!identity?.poa.verified && !identity?.faydaRequired && (
|
||||||
|
<>
|
||||||
|
<TextInput
|
||||||
|
label="Representative's Name"
|
||||||
|
placeholder="Abebe Bikila"
|
||||||
|
error={errors.poaName?.message}
|
||||||
|
{...register("poaName")}
|
||||||
|
/>
|
||||||
|
<SimpleGrid cols={2} spacing="md">
|
||||||
|
<TextInput
|
||||||
|
label="Representative's Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="poa@company.com"
|
||||||
|
error={errors.poaEmail?.message}
|
||||||
|
{...register("poaEmail")}
|
||||||
|
/>
|
||||||
|
<ControlledPhoneField
|
||||||
|
control={control}
|
||||||
|
name="poaPhone"
|
||||||
|
label="Representative's Phone"
|
||||||
|
/>
|
||||||
|
</SimpleGrid>
|
||||||
|
<TextInput
|
||||||
|
label="PoA Location"
|
||||||
|
placeholder="City, Country"
|
||||||
|
error={errors.poaLocation?.message}
|
||||||
|
{...register("poaLocation")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* The paper authorises the representative, so it shows once one
|
||||||
|
exists — or straight away for a freight forwarder, who owes it
|
||||||
|
either way and must not be failed on submit for a file the
|
||||||
|
step never offered. */}
|
||||||
|
{delegationRequired && poaDocumentSetting && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<SmartFileInput
|
||||||
|
file={poaDocumentSetting}
|
||||||
|
value={documentFiles}
|
||||||
|
uploadedKeys={uploadedDocumentKeys}
|
||||||
|
errors={documentFieldErrors}
|
||||||
|
onChange={onDocumentFilesChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,512 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Building2,
|
|
||||||
User,
|
|
||||||
CheckCircle2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
Input,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type OnboardingStep =
|
|
||||||
| "personal"
|
|
||||||
| "company"
|
|
||||||
| "representative";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
// PERSONAL
|
|
||||||
firstName: z.string().min(1, "First name is required"),
|
|
||||||
|
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
|
||||||
|
|
||||||
email: z.string().email("Invalid email address"),
|
|
||||||
|
|
||||||
phoneNumber: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Phone number is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
|
|
||||||
// COMPANY
|
|
||||||
companyName: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company name is required"),
|
|
||||||
|
|
||||||
companyEmail: z
|
|
||||||
.string()
|
|
||||||
.email("Invalid company email"),
|
|
||||||
|
|
||||||
companyPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
|
|
||||||
companyLocation: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company location is required"),
|
|
||||||
|
|
||||||
companyAddress: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company address is required"),
|
|
||||||
|
|
||||||
// REPRESENTATIVE
|
|
||||||
representativeName: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Representative name is required"),
|
|
||||||
|
|
||||||
representativeEmail: z
|
|
||||||
.string()
|
|
||||||
.email("Invalid representative email"),
|
|
||||||
|
|
||||||
representativePhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Representative phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
const stepFields: Record<
|
|
||||||
OnboardingStep,
|
|
||||||
(keyof FormData)[]
|
|
||||||
> = {
|
|
||||||
personal: [
|
|
||||||
"firstName",
|
|
||||||
"lastName",
|
|
||||||
"email",
|
|
||||||
"phoneNumber",
|
|
||||||
],
|
|
||||||
|
|
||||||
company: [
|
|
||||||
"companyName",
|
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyLocation",
|
|
||||||
"companyAddress",
|
|
||||||
],
|
|
||||||
|
|
||||||
representative: [
|
|
||||||
"representativeName",
|
|
||||||
"representativeEmail",
|
|
||||||
"representativePhone",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DjiboutiForwardingAgentForm() {
|
|
||||||
const [step, setStep] =
|
|
||||||
useState<OnboardingStep>("personal");
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
trigger,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
|
|
||||||
defaultValues: {
|
|
||||||
phoneNumber: "",
|
|
||||||
companyPhone: "",
|
|
||||||
representativePhone: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
|
||||||
if (step === "representative") {
|
|
||||||
handleSubmit(onSubmit)();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await trigger(
|
|
||||||
stepFields[step]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
if (step === "personal") {
|
|
||||||
setStep("company");
|
|
||||||
} else {
|
|
||||||
setStep("representative");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
if (step === "company") {
|
|
||||||
setStep("personal");
|
|
||||||
} else if (
|
|
||||||
step === "representative"
|
|
||||||
) {
|
|
||||||
setStep("company");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (
|
|
||||||
data: FormData
|
|
||||||
) => {
|
|
||||||
console.log(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* STEPPER */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between max-w-xl mx-auto relative px-2">
|
|
||||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<User className="size-5" />}
|
|
||||||
active={step === "personal"}
|
|
||||||
completed={
|
|
||||||
step !== "personal"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={
|
|
||||||
<Building2 className="size-5" />
|
|
||||||
}
|
|
||||||
active={step === "company"}
|
|
||||||
completed={
|
|
||||||
step === "representative"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<User className="size-5" />}
|
|
||||||
active={
|
|
||||||
step === "representative"
|
|
||||||
}
|
|
||||||
completed={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
|
||||||
{step === "personal" &&
|
|
||||||
"Step 1 of 3 — Personal Information"}
|
|
||||||
|
|
||||||
{step === "company" &&
|
|
||||||
"Step 2 of 3 — Company Information"}
|
|
||||||
|
|
||||||
{step === "representative" &&
|
|
||||||
"Step 3 of 3 — Representative Information"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FORM */}
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
{/* PERSONAL */}
|
|
||||||
{step === "personal" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.firstName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
First Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Ahmed"
|
|
||||||
{...register("firstName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.firstName]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.lastName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Last Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Ali"
|
|
||||||
{...register("lastName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.lastName]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.email
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>Email</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="agent@company.com"
|
|
||||||
{...register("email")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.email]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="phoneNumber"
|
|
||||||
label="Phone Number"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* COMPANY */}
|
|
||||||
{step === "company" && (
|
|
||||||
<>
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Djibouti Freight Co."
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.companyName]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyEmail
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Email
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="ops@company.com"
|
|
||||||
{...register(
|
|
||||||
"companyEmail"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.companyEmail]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyLocation
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Location / Country
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Djibouti"
|
|
||||||
{...register(
|
|
||||||
"companyLocation"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.companyLocation,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyAddress
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Address
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Rue de Venise"
|
|
||||||
{...register(
|
|
||||||
"companyAddress"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.companyAddress,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* REPRESENTATIVE */}
|
|
||||||
{step ===
|
|
||||||
"representative" && (
|
|
||||||
<>
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.representativeName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Representative Person
|
|
||||||
Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Mohamed Hassan"
|
|
||||||
{...register(
|
|
||||||
"representativeName"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.representativeName,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.representativeEmail
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Representative Email
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="rep@company.com"
|
|
||||||
{...register(
|
|
||||||
"representativeEmail"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.representativeEmail,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="representativePhone"
|
|
||||||
label="Representative Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{/* FOOTER */}
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={prevStep}
|
|
||||||
disabled={step === "personal"}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{step === "representative" ? (
|
|
||||||
"Complete Registration"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepIcon({
|
|
||||||
icon,
|
|
||||||
active,
|
|
||||||
completed,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
active: boolean;
|
|
||||||
completed: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
|
|
||||||
completed
|
|
||||||
? "bg-primary border-primary text-primary-foreground"
|
|
||||||
: active
|
|
||||||
? "bg-background border-primary text-primary shadow-md"
|
|
||||||
: "bg-background border-border text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{completed ? (
|
|
||||||
<CheckCircle2 className="size-5" />
|
|
||||||
) : (
|
|
||||||
icon
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,711 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
Building2,
|
|
||||||
User,
|
|
||||||
FileText,
|
|
||||||
CheckCircle2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
Input,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type OnboardingStep =
|
|
||||||
| "personal"
|
|
||||||
| "company"
|
|
||||||
| "personnel"
|
|
||||||
| "poa";
|
|
||||||
|
|
||||||
const onboardingSchema = z.object({
|
|
||||||
// PERSONAL
|
|
||||||
firstName: z.string().min(1, "First name is required"),
|
|
||||||
lastName: z.string().min(1, "Last name is required"),
|
|
||||||
email: z.string().email("Invalid email address"),
|
|
||||||
phoneNumber: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Phone number is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
|
|
||||||
// COMPANY
|
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
|
||||||
companyEmail: z.string().email("Invalid email address"),
|
|
||||||
companyPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Company phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
companyLocation: z.string().min(1, "Location is required"),
|
|
||||||
companyAddress: z.string().min(1, "Address is required"),
|
|
||||||
|
|
||||||
// LEGAL
|
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, {
|
|
||||||
message: "TIN must be exactly 10 digits",
|
|
||||||
}),
|
|
||||||
|
|
||||||
vatNumber: z.string().min(1, "VAT number is required"),
|
|
||||||
|
|
||||||
fanNumber: z.string().regex(/^\d{16}$/, {
|
|
||||||
message: "FAN must be exactly 16 digits",
|
|
||||||
}),
|
|
||||||
|
|
||||||
// CONTACT PERSON
|
|
||||||
contactPersonName: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Contact person name is required"),
|
|
||||||
|
|
||||||
contactPersonPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "Contact person phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
|
|
||||||
// GENERAL MANAGER
|
|
||||||
generalManagerName: z
|
|
||||||
.string()
|
|
||||||
.min(1, "General manager name is required"),
|
|
||||||
|
|
||||||
generalManagerEmail: z
|
|
||||||
.string()
|
|
||||||
.email("Invalid email"),
|
|
||||||
|
|
||||||
generalManagerPhone: z
|
|
||||||
.string()
|
|
||||||
.min(1, "General manager phone is required")
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
|
|
||||||
// OPTIONAL POA
|
|
||||||
poaName: z.string().optional(),
|
|
||||||
poaPhone: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
|
||||||
poaAddress: z.string().optional(),
|
|
||||||
poaEmail: z.string().optional(),
|
|
||||||
poaLocation: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof onboardingSchema>;
|
|
||||||
|
|
||||||
const stepFields: Record<
|
|
||||||
OnboardingStep,
|
|
||||||
(keyof FormData)[]
|
|
||||||
> = {
|
|
||||||
personal: [
|
|
||||||
"firstName",
|
|
||||||
"lastName",
|
|
||||||
"email",
|
|
||||||
"phoneNumber",
|
|
||||||
],
|
|
||||||
|
|
||||||
company: [
|
|
||||||
"companyName",
|
|
||||||
"companyEmail",
|
|
||||||
"companyPhone",
|
|
||||||
"companyLocation",
|
|
||||||
"companyAddress",
|
|
||||||
"tinNumber",
|
|
||||||
"vatNumber",
|
|
||||||
"fanNumber",
|
|
||||||
],
|
|
||||||
|
|
||||||
personnel: [
|
|
||||||
"contactPersonName",
|
|
||||||
"contactPersonPhone",
|
|
||||||
"generalManagerName",
|
|
||||||
"generalManagerEmail",
|
|
||||||
"generalManagerPhone",
|
|
||||||
],
|
|
||||||
|
|
||||||
poa: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ImportExportOnBoarding() {
|
|
||||||
const [step, setStep] =
|
|
||||||
useState<OnboardingStep>("personal");
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
trigger,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: zodResolver(onboardingSchema),
|
|
||||||
|
|
||||||
defaultValues: {
|
|
||||||
phoneNumber: "",
|
|
||||||
companyPhone: "",
|
|
||||||
contactPersonPhone: "",
|
|
||||||
generalManagerPhone: "",
|
|
||||||
poaPhone: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
|
||||||
if (step === "poa") {
|
|
||||||
handleSubmit(onSubmit)();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await trigger(stepFields[step]);
|
|
||||||
|
|
||||||
if (!isValid) return;
|
|
||||||
|
|
||||||
if (step === "personal") {
|
|
||||||
setStep("company");
|
|
||||||
} else if (step === "company") {
|
|
||||||
setStep("personnel");
|
|
||||||
} else {
|
|
||||||
setStep("poa");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
if (step === "company") {
|
|
||||||
setStep("personal");
|
|
||||||
} else if (step === "personnel") {
|
|
||||||
setStep("company");
|
|
||||||
} else if (step === "poa") {
|
|
||||||
setStep("personnel");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: FormData) => {
|
|
||||||
console.log(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* STEP HEADER */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center justify-between max-w-2xl mx-auto relative px-2">
|
|
||||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<User className="size-5" />}
|
|
||||||
active={step === "personal"}
|
|
||||||
completed={
|
|
||||||
step !== "personal"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<Building2 className="size-5" />}
|
|
||||||
active={step === "company"}
|
|
||||||
completed={
|
|
||||||
step === "personnel" ||
|
|
||||||
step === "poa"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<User className="size-5" />}
|
|
||||||
active={step === "personnel"}
|
|
||||||
completed={step === "poa"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<FileText className="size-5" />}
|
|
||||||
active={step === "poa"}
|
|
||||||
completed={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
|
||||||
{step === "personal" &&
|
|
||||||
"Step 1 of 4 — Personal Information"}
|
|
||||||
|
|
||||||
{step === "company" &&
|
|
||||||
"Step 2 of 4 — Company Information"}
|
|
||||||
|
|
||||||
{step === "personnel" &&
|
|
||||||
"Step 3 of 4 — Personnel Information"}
|
|
||||||
|
|
||||||
{step === "poa" &&
|
|
||||||
"Step 4 of 4 — Power of Attorney"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="flex flex-col gap-4"
|
|
||||||
>
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
{/* PERSONAL */}
|
|
||||||
{step === "personal" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.firstName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
First Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="John"
|
|
||||||
{...register("firstName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.firstName]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.lastName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Last Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Doe"
|
|
||||||
{...register("lastName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.lastName]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.email
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>Email</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="john@example.com"
|
|
||||||
{...register("email")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.email]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="phoneNumber"
|
|
||||||
label="Phone Number"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* COMPANY */}
|
|
||||||
{step === "company" && (
|
|
||||||
<>
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Global Logistics Ltd"
|
|
||||||
{...register("companyName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.companyName]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyEmail
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Email
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="ops@company.com"
|
|
||||||
{...register(
|
|
||||||
"companyEmail"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.companyEmail]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyLocation
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Location
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Addis Ababa"
|
|
||||||
{...register(
|
|
||||||
"companyLocation"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.companyLocation,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.companyAddress
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Company Address
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Bole, Woreda 03"
|
|
||||||
{...register(
|
|
||||||
"companyAddress"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.companyAddress,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.tinNumber
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
TIN Number
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
maxLength={10}
|
|
||||||
placeholder="1234567890"
|
|
||||||
{...register("tinNumber")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.tinNumber]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.vatNumber
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
VAT Number
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="VAT123456"
|
|
||||||
{...register("vatNumber")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.vatNumber]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.fanNumber
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
FAN Number
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
maxLength={16}
|
|
||||||
placeholder="1234567890123456"
|
|
||||||
{...register("fanNumber")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[errors.fanNumber]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PERSONNEL */}
|
|
||||||
{step === "personnel" && (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
|
||||||
Contact Person
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.contactPersonName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
Contact Person Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Jane Smith"
|
|
||||||
{...register(
|
|
||||||
"contactPersonName"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.contactPersonName,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="contactPersonPhone"
|
|
||||||
label="Contact Person Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr className="border-border" />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3">
|
|
||||||
General Manager
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field
|
|
||||||
className="col-span-2"
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.generalManagerName
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
General Manager Name
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Abebe Bikila"
|
|
||||||
{...register(
|
|
||||||
"generalManagerName"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.generalManagerName,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field
|
|
||||||
data-invalid={Boolean(
|
|
||||||
errors.generalManagerEmail
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<FieldLabel>
|
|
||||||
General Manager Email
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="gm@company.com"
|
|
||||||
{...register(
|
|
||||||
"generalManagerEmail"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FieldError
|
|
||||||
errors={[
|
|
||||||
errors.generalManagerEmail,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="generalManagerPhone"
|
|
||||||
label="General Manager Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* POA */}
|
|
||||||
{step === "poa" && (
|
|
||||||
<>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Power of Attorney details are
|
|
||||||
optional.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>PoA Name</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Authorized Representative"
|
|
||||||
{...register("poaName")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>
|
|
||||||
PoA Email
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
type="email"
|
|
||||||
placeholder="poa@company.com"
|
|
||||||
{...register("poaEmail")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="poaPhone"
|
|
||||||
label="PoA Phone"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>
|
|
||||||
PoA Location
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="City, Country"
|
|
||||||
{...register("poaLocation")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel>
|
|
||||||
PoA Address
|
|
||||||
</FieldLabel>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Full Address"
|
|
||||||
{...register("poaAddress")}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{/* FOOTER */}
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={prevStep}
|
|
||||||
disabled={step === "personal"}
|
|
||||||
>
|
|
||||||
<ArrowLeft />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={nextStep}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{step === "poa" ? (
|
|
||||||
"Complete Registration"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepIcon({
|
|
||||||
icon,
|
|
||||||
active,
|
|
||||||
completed,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
active: boolean;
|
|
||||||
completed: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
|
|
||||||
completed
|
|
||||||
? "bg-primary border-primary text-primary-foreground"
|
|
||||||
: active
|
|
||||||
? "bg-background border-primary text-primary shadow-md"
|
|
||||||
: "bg-background border-border text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{completed ? (
|
|
||||||
<CheckCircle2 className="size-5" />
|
|
||||||
) : (
|
|
||||||
icon
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ArrowRight,
|
|
||||||
User,
|
|
||||||
Truck,
|
|
||||||
CheckCircle2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Field,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLabel,
|
|
||||||
Input,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
type Step = "personal" | "transport";
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
// PERSONAL
|
|
||||||
firstName: z.string().min(1),
|
|
||||||
lastName: z.string().min(1),
|
|
||||||
email: z.string().email(),
|
|
||||||
phoneNumber: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.refine(isValidPhone, "Enter a valid phone number"),
|
|
||||||
|
|
||||||
// TRANSPORT
|
|
||||||
fanNumber: z.string().min(1),
|
|
||||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
|
||||||
|
|
||||||
truckType: z.enum([
|
|
||||||
"Casoni",
|
|
||||||
"Truck Trailer",
|
|
||||||
"High Bed",
|
|
||||||
"Low Bed",
|
|
||||||
"Others",
|
|
||||||
]),
|
|
||||||
|
|
||||||
plateNumber: z.string().min(1),
|
|
||||||
|
|
||||||
plateNumber2: z.string().optional(),
|
|
||||||
|
|
||||||
vehicleModel: z.string().min(1),
|
|
||||||
yearOfManufacturing: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
const stepFields: Record<Step, (keyof FormData)[]> = {
|
|
||||||
personal: [
|
|
||||||
"firstName",
|
|
||||||
"lastName",
|
|
||||||
"email",
|
|
||||||
"phoneNumber",
|
|
||||||
],
|
|
||||||
transport: [
|
|
||||||
"fanNumber",
|
|
||||||
"tinNumber",
|
|
||||||
"truckType",
|
|
||||||
"plateNumber",
|
|
||||||
"plateNumber2",
|
|
||||||
"vehicleModel",
|
|
||||||
"yearOfManufacturing",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function TransporterOnboarding() {
|
|
||||||
const [step, setStep] = useState<Step>("personal");
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
control,
|
|
||||||
handleSubmit,
|
|
||||||
trigger,
|
|
||||||
watch,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
} = useForm<FormData>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
phoneNumber: "",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const truckType = watch("truckType");
|
|
||||||
|
|
||||||
const nextStep = async () => {
|
|
||||||
const valid = await trigger(stepFields[step]);
|
|
||||||
if (!valid) return;
|
|
||||||
|
|
||||||
if (step === "personal") setStep("transport");
|
|
||||||
else handleSubmit(onSubmit)();
|
|
||||||
};
|
|
||||||
|
|
||||||
const prevStep = () => {
|
|
||||||
if (step === "transport") setStep("personal");
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = (data: FormData) => {
|
|
||||||
console.log("TRANSPORTER:", data);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* STEPPER */}
|
|
||||||
<div className="mb-8 lg:col-span-2">
|
|
||||||
<div className="flex items-center justify-between max-w-lg mx-auto relative px-2">
|
|
||||||
<div className="absolute top-1/2 left-0 w-full h-0.5 bg-border -translate-y-1/2 z-0" />
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<User className="size-5" />}
|
|
||||||
active={step === "personal"}
|
|
||||||
completed={step !== "personal"}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<StepIcon
|
|
||||||
icon={<Truck className="size-5" />}
|
|
||||||
active={step === "transport"}
|
|
||||||
completed={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground mt-3">
|
|
||||||
{step === "personal" && "Step 1 of 2 — Personal Information"}
|
|
||||||
{step === "transport" && "Step 2 of 2 — Transport Information"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FORM */}
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-4">
|
|
||||||
<FieldGroup className="gap-4">
|
|
||||||
|
|
||||||
{/* PERSONAL */}
|
|
||||||
{step === "personal" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field data-invalid={!!errors.firstName}>
|
|
||||||
<FieldLabel>First Name</FieldLabel>
|
|
||||||
<Input {...register("firstName")} />
|
|
||||||
<FieldError errors={[errors.firstName]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.lastName}>
|
|
||||||
<FieldLabel>Last Name</FieldLabel>
|
|
||||||
<Input {...register("lastName")} />
|
|
||||||
<FieldError errors={[errors.lastName]} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field data-invalid={!!errors.email}>
|
|
||||||
<FieldLabel>Email</FieldLabel>
|
|
||||||
<Input type="email" {...register("email")} />
|
|
||||||
<FieldError errors={[errors.email]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="phoneNumber"
|
|
||||||
label="Phone Number"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* TRANSPORT */}
|
|
||||||
{step === "transport" && (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field data-invalid={!!errors.fanNumber}>
|
|
||||||
<FieldLabel>FAN Number</FieldLabel>
|
|
||||||
<Input {...register("fanNumber")} />
|
|
||||||
<FieldError errors={[errors.fanNumber]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.tinNumber}>
|
|
||||||
<FieldLabel>TIN Number</FieldLabel>
|
|
||||||
<Input maxLength={10} inputMode="numeric" {...register("tinNumber")} />
|
|
||||||
<FieldError errors={[errors.tinNumber]} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.truckType}>
|
|
||||||
<FieldLabel>Truck Type</FieldLabel>
|
|
||||||
<select
|
|
||||||
className="w-full border rounded-md p-2 bg-background"
|
|
||||||
{...register("truckType")}
|
|
||||||
>
|
|
||||||
<option value="">Select type</option>
|
|
||||||
<option value="Casoni">Casoni</option>
|
|
||||||
<option value="Truck Trailer">Truck Trailer</option>
|
|
||||||
<option value="High Bed">High Bed</option>
|
|
||||||
<option value="Low Bed">Low Bed</option>
|
|
||||||
<option value="Others">Others</option>
|
|
||||||
</select>
|
|
||||||
<FieldError errors={[errors.truckType]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field data-invalid={!!errors.plateNumber}>
|
|
||||||
<FieldLabel>Plate Number</FieldLabel>
|
|
||||||
<Input {...register("plateNumber")} />
|
|
||||||
<FieldError errors={[errors.plateNumber]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{truckType === "Casoni" && (
|
|
||||||
<Field data-invalid={!!errors.plateNumber2}>
|
|
||||||
<FieldLabel>Second Plate Number (Casoni)</FieldLabel>
|
|
||||||
<Input {...register("plateNumber2")} />
|
|
||||||
<FieldError errors={[errors.plateNumber2]} />
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Field data-invalid={!!errors.vehicleModel}>
|
|
||||||
<FieldLabel>Vehicle Model</FieldLabel>
|
|
||||||
<Input {...register("vehicleModel")} />
|
|
||||||
<FieldError errors={[errors.vehicleModel]} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field data-invalid={!!errors.yearOfManufacturing}>
|
|
||||||
<FieldLabel>Year of Manufacturing</FieldLabel>
|
|
||||||
<Input {...register("yearOfManufacturing")} />
|
|
||||||
<FieldError errors={[errors.yearOfManufacturing]} />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
{/* FOOTER */}
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
|
||||||
<Button type="button" variant="outline" onClick={prevStep} disabled={step === "personal"}>
|
|
||||||
<ArrowLeft />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button type="button" onClick={nextStep} disabled={isSubmitting}>
|
|
||||||
{step === "transport" ? (
|
|
||||||
"Complete Registration"
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Next Step
|
|
||||||
<ArrowRight />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StepIcon({
|
|
||||||
icon,
|
|
||||||
active,
|
|
||||||
completed,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
active: boolean;
|
|
||||||
completed: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`relative z-10 flex h-10 w-10 items-center justify-center rounded-full border-2 transition-all ${
|
|
||||||
completed
|
|
||||||
? "bg-primary border-primary text-primary-foreground"
|
|
||||||
: active
|
|
||||||
? "bg-background border-primary text-primary shadow-md"
|
|
||||||
: "bg-background border-border text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{completed ? <CheckCircle2 className="size-5" /> : icon}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,22 @@
|
|||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type {
|
import type {
|
||||||
CompanyProfileInput,
|
CompanyProfileInput,
|
||||||
CreateCompanyPayload,
|
CreateCompanyPayload,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import type { AuthUser } from "@/types/auth";
|
import type { AuthUser } from "@/types/auth";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
import { extractApiError } from "@/utils/result";
|
import { extractApiError } from "@/utils/result";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
Select,
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
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 { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
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 { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||||
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
|
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({
|
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||||
companyName: z.string().min(1, "Company name is required"),
|
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"),
|
companyLocation: z.string().min(1, "Location is required"),
|
||||||
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
|
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
|
||||||
// no standalone input.
|
// no standalone input.
|
||||||
@@ -108,8 +104,6 @@ export default function TabCompanyProfile({
|
|||||||
if (profile) {
|
if (profile) {
|
||||||
return {
|
return {
|
||||||
companyName: profile.companyName,
|
companyName: profile.companyName,
|
||||||
companyEmail: profile.companyEmail ?? "",
|
|
||||||
companyPhone: profile.companyPhone ?? "",
|
|
||||||
companyLocation: profile.companyLocation,
|
companyLocation: profile.companyLocation,
|
||||||
companyAddress: profile.companyAddress ?? "",
|
companyAddress: profile.companyAddress ?? "",
|
||||||
tinNumber: profile.tinNumber,
|
tinNumber: profile.tinNumber,
|
||||||
@@ -130,8 +124,6 @@ export default function TabCompanyProfile({
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
companyEmail: "",
|
|
||||||
companyPhone: "",
|
|
||||||
companyLocation: "",
|
companyLocation: "",
|
||||||
companyAddress: "",
|
companyAddress: "",
|
||||||
tinNumber: "",
|
tinNumber: "",
|
||||||
@@ -173,31 +165,6 @@ export default function TabCompanyProfile({
|
|||||||
);
|
);
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
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
|
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||||
// typed directly.
|
// typed directly.
|
||||||
const region = watch("region");
|
const region = watch("region");
|
||||||
@@ -221,7 +188,9 @@ export default function TabCompanyProfile({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
setValue("licenceNumber", data.licenceNumber, { shouldDirty: true });
|
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("dateRegistered", data.dateRegistered, { shouldDirty: true });
|
||||||
setValue("renewedFrom", data.renewedFrom, { shouldDirty: true });
|
setValue("renewedFrom", data.renewedFrom, { shouldDirty: true });
|
||||||
setValue("renewalDate", data.renewalDate, { shouldDirty: true });
|
setValue("renewalDate", data.renewalDate, { shouldDirty: true });
|
||||||
@@ -260,8 +229,6 @@ export default function TabCompanyProfile({
|
|||||||
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
companyEmail: data.companyEmail,
|
|
||||||
companyPhone: data.companyPhone,
|
|
||||||
companyLocation: data.companyLocation,
|
companyLocation: data.companyLocation,
|
||||||
companyAddress: data.companyAddress,
|
companyAddress: data.companyAddress,
|
||||||
vatNumber: data.vatNumber ?? "",
|
vatNumber: data.vatNumber ?? "",
|
||||||
@@ -329,8 +296,11 @@ export default function TabCompanyProfile({
|
|||||||
(mutation.isError ? extractApiError(mutation.error).message : null);
|
(mutation.isError ? extractApiError(mutation.error).message : null);
|
||||||
|
|
||||||
const pendingOwnerReview = Boolean(
|
const pendingOwnerReview = Boolean(
|
||||||
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
(
|
||||||
?.faydaIdentity?.ownerFaydaSub,
|
profile?.pendingChanges as {
|
||||||
|
faydaIdentity?: Record<string, unknown>;
|
||||||
|
} | null
|
||||||
|
)?.faydaIdentity?.ownerFaydaSub,
|
||||||
);
|
);
|
||||||
|
|
||||||
// During onboarding the role selection gates the form: nothing else shows
|
// During onboarding the role selection gates the form: nothing else shows
|
||||||
@@ -346,185 +316,163 @@ export default function TabCompanyProfile({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<Group gap="sm" mb="xs">
|
<Group gap="sm" mb="xs">
|
||||||
<Building2 size={20} />
|
<Building2 size={20} />
|
||||||
<Title order={3}>Company Profile</Title>
|
<Title order={3}>Company Profile</Title>
|
||||||
</Group>
|
|
||||||
<Text c="edr-muted" size="sm" mb="lg">
|
|
||||||
{isCreate
|
|
||||||
? "Enter your company registration details to get started"
|
|
||||||
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
|
||||||
<Stack gap="xl">
|
|
||||||
<StepSection
|
|
||||||
index={1}
|
|
||||||
title="VAT number"
|
|
||||||
status={watch("vatNumber") ? "done" : "todo"}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
label="VAT Number"
|
|
||||||
placeholder="e.g. 0012345678"
|
|
||||||
maxLength={10}
|
|
||||||
error={errors.vatNumber?.message}
|
|
||||||
{...register("vatNumber")}
|
|
||||||
/>
|
|
||||||
</StepSection>
|
|
||||||
|
|
||||||
{identity && (
|
|
||||||
<StepSection
|
|
||||||
index={2}
|
|
||||||
title="Owner identity"
|
|
||||||
subtitle={
|
|
||||||
verifiedIdentity
|
|
||||||
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
|
|
||||||
: "The company owner's passport number."
|
|
||||||
}
|
|
||||||
status={
|
|
||||||
verifiedIdentity
|
|
||||||
? identity.owner.verified
|
|
||||||
? "done"
|
|
||||||
: identity.faydaRequired
|
|
||||||
? "blocked"
|
|
||||||
: "todo"
|
|
||||||
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
|
||||||
? "done"
|
|
||||||
: identity.passportRequired
|
|
||||||
? "blocked"
|
|
||||||
: "todo"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FaydaVerifyPanel
|
|
||||||
subject="owner"
|
|
||||||
title="Company owner"
|
|
||||||
state={identity.owner}
|
|
||||||
required={identity.faydaRequired}
|
|
||||||
disabled={mutation.isPending}
|
|
||||||
pendingReview={pendingOwnerReview}
|
|
||||||
/>
|
|
||||||
{identity.passportRequired && (
|
|
||||||
<TextInput
|
|
||||||
label="Owner Passport Number"
|
|
||||||
placeholder="P1234567"
|
|
||||||
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
|
|
||||||
error={errors.ownerPassportNumber?.message}
|
|
||||||
{...register("ownerPassportNumber")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Read-only while the verified owner (or eTrade, or the account)
|
|
||||||
supplies them. Fayda's email/phone claims are optional, so
|
|
||||||
when nothing can be derived these become typeable — the API
|
|
||||||
requires both, and showing an empty read-only field is a save
|
|
||||||
that can never succeed. */}
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
{derivedEmail ? (
|
|
||||||
<ReadOnlyField label="Company email" value={derivedEmail} />
|
|
||||||
) : (
|
|
||||||
<TextInput
|
|
||||||
label="Company Email"
|
|
||||||
type="email"
|
|
||||||
description="We couldn't find one on your verified identity or account — please enter it."
|
|
||||||
error={errors.companyEmail?.message}
|
|
||||||
{...register("companyEmail")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{derivedPhone ? (
|
|
||||||
<ReadOnlyField label="Company phone" value={derivedPhone} />
|
|
||||||
) : (
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="companyPhone"
|
|
||||||
label="Company Phone"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
</StepSection>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<StepSection
|
|
||||||
index={3}
|
|
||||||
title="Company TIN"
|
|
||||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
|
||||||
status={
|
|
||||||
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ETradeInfo
|
|
||||||
tin={watch("tinNumber")}
|
|
||||||
register={register("tinNumber")}
|
|
||||||
error={errors.tinNumber?.message}
|
|
||||||
onDataLoaded={handleETradeDataLoaded}
|
|
||||||
onStatusChange={setTinStatus}
|
|
||||||
alreadyVerified={hasRegistrationDetails}
|
|
||||||
/>
|
|
||||||
{tinVerified && (
|
|
||||||
<EtradeLockedCard
|
|
||||||
tin={watch("tinNumber")}
|
|
||||||
register={register}
|
|
||||||
watch={watch}
|
|
||||||
errors={errors}
|
|
||||||
control={control}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</StepSection>
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label="Location"
|
|
||||||
placeholder="Addis Ababa, Ethiopia"
|
|
||||||
error={errors.companyLocation?.message}
|
|
||||||
{...register("companyLocation")}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Group
|
|
||||||
justify="space-between"
|
|
||||||
mt="xl"
|
|
||||||
pt="md"
|
|
||||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
|
||||||
>
|
|
||||||
<Group gap="xs">
|
|
||||||
{mutation.isSuccess && !isCreate && (
|
|
||||||
<Group gap={6} c="green">
|
|
||||||
<CheckCircle2 size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
Saved successfully
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
{saveErrorMessage && (
|
|
||||||
<Group gap={6} c="red">
|
|
||||||
<XCircle size={16} />
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{saveErrorMessage}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Text c="edr-muted" size="sm" mb="lg">
|
||||||
{!isCreate && (
|
{isCreate
|
||||||
<Button
|
? "Enter your company registration details to get started"
|
||||||
type="button"
|
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
||||||
variant="outline"
|
</Text>
|
||||||
disabled={mutation.isPending || !isDirty}
|
|
||||||
onClick={() => reset()}
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>
|
||||||
|
<Stack gap="xl">
|
||||||
|
<StepSection
|
||||||
|
index={1}
|
||||||
|
title="VAT number"
|
||||||
|
status={watch("vatNumber") ? "done" : "todo"}
|
||||||
>
|
>
|
||||||
Reset
|
<TextInput
|
||||||
</Button>
|
label="VAT Number"
|
||||||
)}
|
placeholder="e.g. 0012345678"
|
||||||
<Button
|
maxLength={10}
|
||||||
type="submit"
|
error={errors.vatNumber?.message}
|
||||||
leftSection={<Save size={16} />}
|
{...register("vatNumber")}
|
||||||
loading={mutation.isPending}
|
/>
|
||||||
|
</StepSection>
|
||||||
|
|
||||||
|
{identity && (
|
||||||
|
<StepSection
|
||||||
|
index={2}
|
||||||
|
title="Owner identity"
|
||||||
|
subtitle={
|
||||||
|
verifiedIdentity
|
||||||
|
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
|
||||||
|
: "The company owner's passport number."
|
||||||
|
}
|
||||||
|
status={
|
||||||
|
verifiedIdentity
|
||||||
|
? identity.owner.verified
|
||||||
|
? "done"
|
||||||
|
: identity.faydaRequired
|
||||||
|
? "blocked"
|
||||||
|
: "todo"
|
||||||
|
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
||||||
|
? "done"
|
||||||
|
: identity.passportRequired
|
||||||
|
? "blocked"
|
||||||
|
: "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FaydaVerifyPanel
|
||||||
|
subject="owner"
|
||||||
|
title="Company owner"
|
||||||
|
state={identity.owner}
|
||||||
|
required={identity.faydaRequired}
|
||||||
|
disabled={mutation.isPending}
|
||||||
|
pendingReview={pendingOwnerReview}
|
||||||
|
/>
|
||||||
|
{identity.passportRequired && (
|
||||||
|
<TextInput
|
||||||
|
label="Owner Passport Number"
|
||||||
|
placeholder="P1234567"
|
||||||
|
description="The owner's identity credential — Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
|
||||||
|
error={errors.ownerPassportNumber?.message}
|
||||||
|
{...register("ownerPassportNumber")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StepSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StepSection
|
||||||
|
index={3}
|
||||||
|
title="Company TIN"
|
||||||
|
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||||
|
status={
|
||||||
|
tinVerified
|
||||||
|
? "done"
|
||||||
|
: tinStatus === "taken"
|
||||||
|
? "blocked"
|
||||||
|
: "todo"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ETradeInfo
|
||||||
|
tin={watch("tinNumber")}
|
||||||
|
register={register("tinNumber")}
|
||||||
|
error={errors.tinNumber?.message}
|
||||||
|
onDataLoaded={handleETradeDataLoaded}
|
||||||
|
onStatusChange={setTinStatus}
|
||||||
|
alreadyVerified={hasRegistrationDetails}
|
||||||
|
/>
|
||||||
|
{tinVerified && (
|
||||||
|
<EtradeLockedCard
|
||||||
|
tin={watch("tinNumber")}
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
control={control}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</StepSection>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Location"
|
||||||
|
placeholder="Addis Ababa, Ethiopia"
|
||||||
|
error={errors.companyLocation?.message}
|
||||||
|
{...register("companyLocation")}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
mt="xl"
|
||||||
|
pt="md"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-edr-border-0)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{isCreate ? "Continue" : "Save Changes"}
|
<Group gap="xs">
|
||||||
</Button>
|
{mutation.isSuccess && !isCreate && (
|
||||||
</Group>
|
<Group gap={6} c="green">
|
||||||
</Group>
|
<CheckCircle2 size={16} />
|
||||||
</form>
|
<Text size="sm" fw={500}>
|
||||||
</Card>
|
Saved successfully
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{saveErrorMessage && (
|
||||||
|
<Group gap={6} c="red">
|
||||||
|
<XCircle size={16} />
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{saveErrorMessage}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Group gap="md">
|
||||||
|
{!isCreate && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={mutation.isPending || !isDirty}
|
||||||
|
onClick={() => reset()}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<Save size={16} />}
|
||||||
|
loading={mutation.isPending}
|
||||||
|
>
|
||||||
|
{isCreate ? "Continue" : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
@@ -545,7 +493,9 @@ function EtradeLockedCard({
|
|||||||
tin: string;
|
tin: string;
|
||||||
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
||||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
errors: ReturnType<
|
||||||
|
typeof useForm<CompanyProfileFormData>
|
||||||
|
>["formState"]["errors"];
|
||||||
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
|
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
|
||||||
}) {
|
}) {
|
||||||
const companyName = watch("companyName");
|
const companyName = watch("companyName");
|
||||||
@@ -562,10 +512,19 @@ function EtradeLockedCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<SimpleGrid cols={2} spacing="sm">
|
<SimpleGrid cols={2} spacing="sm">
|
||||||
<LockedField label="Company Name" name="companyName" register={register} watch={watch} errors={errors} />
|
<LockedField
|
||||||
|
label="Company Name"
|
||||||
|
name="companyName"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
|
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
|
||||||
<ReadOnlyField label="Status" value={watch("statusDescription")} />
|
<ReadOnlyField label="Status" value={watch("statusDescription")} />
|
||||||
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
|
<ReadOnlyField
|
||||||
|
label="Date Registered"
|
||||||
|
value={watch("dateRegistered")}
|
||||||
|
/>
|
||||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||||
@@ -576,10 +535,34 @@ function EtradeLockedCard({
|
|||||||
) : (
|
) : (
|
||||||
<RegionSelect control={control} error={errors.region?.message} />
|
<RegionSelect control={control} error={errors.region?.message} />
|
||||||
)}
|
)}
|
||||||
<LockedField label="Zone" name="zone" register={register} watch={watch} errors={errors} />
|
<LockedField
|
||||||
<LockedField label="Woreda" name="woreda" register={register} watch={watch} errors={errors} />
|
label="Zone"
|
||||||
<LockedField label="Kebele" name="kebele" register={register} watch={watch} errors={errors} />
|
name="zone"
|
||||||
<LockedField label="House No" name="houseNo" register={register} watch={watch} errors={errors} />
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<LockedField
|
||||||
|
label="Woreda"
|
||||||
|
name="woreda"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<LockedField
|
||||||
|
label="Kebele"
|
||||||
|
name="kebele"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
|
<LockedField
|
||||||
|
label="House No"
|
||||||
|
name="houseNo"
|
||||||
|
register={register}
|
||||||
|
watch={watch}
|
||||||
|
errors={errors}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -596,7 +579,9 @@ function LockedField({
|
|||||||
name: keyof CompanyProfileFormData;
|
name: keyof CompanyProfileFormData;
|
||||||
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
||||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
errors: ReturnType<
|
||||||
|
typeof useForm<CompanyProfileFormData>
|
||||||
|
>["formState"]["errors"];
|
||||||
}) {
|
}) {
|
||||||
const value = watch(name) as string | undefined;
|
const value = watch(name) as string | undefined;
|
||||||
// A value that fails validation unlocks too — rendering a rejected value
|
// A value that fails validation unlocks too — rendering a rejected value
|
||||||
|
|||||||
@@ -194,11 +194,11 @@ export interface OnboardingRequirements {
|
|||||||
|
|
||||||
export interface CompanyProfileInput {
|
export interface CompanyProfileInput {
|
||||||
type:
|
type:
|
||||||
| "importer"
|
| "importer"
|
||||||
| "exporter"
|
| "exporter"
|
||||||
| "freight_forwarder"
|
| "freight_forwarder"
|
||||||
| "dj_freight_forwarder"
|
| "dj_freight_forwarder"
|
||||||
| "transporter";
|
| "transporter";
|
||||||
businessLicense?: string;
|
businessLicense?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +206,6 @@ export interface CreateCompanyPayload {
|
|||||||
companyType?: string;
|
companyType?: string;
|
||||||
nationality?: CompanyNationality;
|
nationality?: CompanyNationality;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyEmail?: string;
|
|
||||||
companyPhone?: string;
|
|
||||||
companyLocation?: string;
|
companyLocation?: string;
|
||||||
companyAddress?: string;
|
companyAddress?: string;
|
||||||
tin?: string;
|
tin?: string;
|
||||||
@@ -460,9 +458,9 @@ export const companiesService = {
|
|||||||
|
|
||||||
/** The current company's open profile change request (pending/rejected), or null. */
|
/** The current company's open profile change request (pending/rejected), or null. */
|
||||||
getChangeRequest: async (): Promise<ChangeRequestResponse | null> => {
|
getChangeRequest: async (): Promise<ChangeRequestResponse | null> => {
|
||||||
const response = await client.get<ApiResponse<ChangeRequestResponse | null>>(
|
const response = await client.get<
|
||||||
URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST,
|
ApiResponse<ChangeRequestResponse | null>
|
||||||
);
|
>(URL_CONSTANTS.COMPANIES_API.PROFILE_CHANGE_REQUEST);
|
||||||
return unwrap(response.data);
|
return unwrap(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ export interface ProfileResponse {
|
|||||||
companyType: string;
|
companyType: string;
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
companyProfiles: CompanyProfileResponse[];
|
companyProfiles: CompanyProfileResponse[];
|
||||||
companyEmail: string | null;
|
|
||||||
companyPhone: string | null;
|
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string | null;
|
companyAddress: string | null;
|
||||||
tinNumber: string;
|
tinNumber: string;
|
||||||
@@ -68,8 +66,6 @@ export interface ProfileResponse {
|
|||||||
export interface UpdateProfilePayload {
|
export interface UpdateProfilePayload {
|
||||||
nationality?: "ethiopian" | "foreign";
|
nationality?: "ethiopian" | "foreign";
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
companyEmail?: string;
|
|
||||||
companyPhone?: string;
|
|
||||||
companyLocation?: string;
|
companyLocation?: string;
|
||||||
companyAddress?: string;
|
companyAddress?: string;
|
||||||
tin?: string;
|
tin?: string;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { BaseEntity } from "../common";
|
import type { BaseEntity } from "../common";
|
||||||
import { ClearanceNextAction, ContractDocPhase, IClearanceMilestone } from "./contracts";
|
import {
|
||||||
|
ClearanceNextAction,
|
||||||
|
ContractDocPhase,
|
||||||
|
IClearanceMilestone,
|
||||||
|
} from "./contracts";
|
||||||
|
|
||||||
export * from "./dropdown_settings";
|
export * from "./dropdown_settings";
|
||||||
export * from "./file_upload_settings";
|
export * from "./file_upload_settings";
|
||||||
@@ -183,7 +187,7 @@ export enum InvoiceSource {
|
|||||||
FirstMile = "firstmile",
|
FirstMile = "firstmile",
|
||||||
LastMile = "lastmile",
|
LastMile = "lastmile",
|
||||||
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
/** Customs clearance service fee — billed on the booking invoice with the freight. */
|
||||||
Clearance = "clearance"
|
Clearance = "clearance",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SchedulingStatus {
|
export enum SchedulingStatus {
|
||||||
@@ -444,8 +448,6 @@ export interface ICustomer extends BaseEntity {
|
|||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyEmail: string;
|
|
||||||
companyPhone: string;
|
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string;
|
companyAddress: string;
|
||||||
contactPersonName: string;
|
contactPersonName: string;
|
||||||
@@ -471,8 +473,6 @@ export interface CreateCustomerDto {
|
|||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
companyEmail: string;
|
|
||||||
companyPhone: string;
|
|
||||||
companyLocation: string;
|
companyLocation: string;
|
||||||
companyAddress: string;
|
companyAddress: string;
|
||||||
contactPersonName: string;
|
contactPersonName: string;
|
||||||
@@ -758,7 +758,10 @@ export interface IBooking extends BaseEntity {
|
|||||||
*/
|
*/
|
||||||
isSplit?: boolean;
|
isSplit?: boolean;
|
||||||
/** What this booking carried before it was reduced by a split (bulk tons / units per size). */
|
/** What this booking carried before it was reduced by a split (bulk tons / units per size). */
|
||||||
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
|
preSplitQuantities?: {
|
||||||
|
bulkTons?: number;
|
||||||
|
bySize?: Record<string, number>;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PricingBreakdownLineItem {
|
export interface PricingBreakdownLineItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user