mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
refactor(api): extract self-service attributes for immediate application and poa verification improvement
This commit is contained in:
@@ -10,12 +10,17 @@ import { POA_DELEGATION_FILE_KEY } from "../file-upload-settings/poa-delegation.
|
|||||||
* come from the verified payload, not typed. Fayda's userinfo carries no
|
* come from the verified payload, not typed. Fayda's userinfo carries no
|
||||||
* national ID number, so none is collected or derived here.
|
* national ID number, so none is collected or derived here.
|
||||||
*
|
*
|
||||||
* - Ethiopian company: the owner (and its PoA, once named) is verified through
|
* Only the OWNER's credential varies by nationality:
|
||||||
* Fayda, and their details can't be edited afterwards.
|
* - Ethiopian company: the owner is verified through Fayda.
|
||||||
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
* - Foreign company: Fayda is an Ethiopian national ID, so the owner instead
|
||||||
* supplies a typed passport number — required on its own, whether or not the
|
* supplies a typed passport number — required on its own, whether or not the
|
||||||
* owner also completes a (purely optional) Fayda verification.
|
* owner also completes a (purely optional) Fayda verification.
|
||||||
*
|
*
|
||||||
|
* The PoA does not vary. A representative acts for the company inside Ethiopia
|
||||||
|
* whoever owns it, so a PoA is always an Ethiopian holding a Fayda ID: once one
|
||||||
|
* is named, both nationalities must verify them, and their details come from
|
||||||
|
* the verified payload rather than the form.
|
||||||
|
*
|
||||||
* The owner is NOT the general manager — GM is a separate, plain typed role
|
* The owner is NOT the general manager — GM is a separate, plain typed role
|
||||||
* the portal offers a "same as owner" copy for, but it is never itself
|
* the portal offers a "same as owner" copy for, but it is never itself
|
||||||
* Fayda-verified or gated on.
|
* Fayda-verified or gated on.
|
||||||
@@ -107,6 +112,7 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
},
|
},
|
||||||
changeRequestRepo: {
|
changeRequestRepo: {
|
||||||
findPendingByCompanyId: jest.fn(async () => null),
|
findPendingByCompanyId: jest.fn(async () => null),
|
||||||
|
findLatestOpenByCompanyId: jest.fn(async () => null),
|
||||||
findByCompanyId: jest.fn(async () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
id: "cr-1",
|
id: "cr-1",
|
||||||
@@ -229,9 +235,27 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stages the change for review on an approved company", async () => {
|
it("stages an owner re-verification for review on an approved company", async () => {
|
||||||
// Swapping the person who can act for a live company is exactly what the
|
// The owner is the live company's identity proof, so re-verifying one is
|
||||||
// backoffice review exists for, so it must not rewrite the row directly.
|
// exactly what the backoffice review exists for: it must not rewrite the
|
||||||
|
// row directly.
|
||||||
|
const { service, ctx, deps } = makeService({
|
||||||
|
status: CompanyStatus.Active,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.completeIdentityVerification("user-1", {
|
||||||
|
subject: "owner",
|
||||||
|
code: "c",
|
||||||
|
state: "s",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
||||||
|
expect(ctx.attributes.ownerFaydaSub).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies a PoA verification live on an approved company", async () => {
|
||||||
|
// The PoA is personnel the company names for itself — the delegation paper
|
||||||
|
// is what a reviewer actually judges — so it does not go to review.
|
||||||
const { service, ctx, deps } = makeService({
|
const { service, ctx, deps } = makeService({
|
||||||
status: CompanyStatus.Active,
|
status: CompanyStatus.Active,
|
||||||
});
|
});
|
||||||
@@ -242,8 +266,8 @@ describe("Fayda identity verification binds a person to the company", () => {
|
|||||||
state: "s",
|
state: "s",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(deps.changeRequestRepo.create).toHaveBeenCalled();
|
expect(deps.changeRequestRepo.create).not.toHaveBeenCalled();
|
||||||
expect(ctx.attributes.poaFaydaSub).toBeUndefined();
|
expect(ctx.attributes.poaFaydaSub).toBe("new-sub");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses to rename a verified person by hand", async () => {
|
it("refuses to rename a verified person by hand", async () => {
|
||||||
@@ -367,7 +391,29 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("grants the forwarder role to a foreign company with an owner passport and no Fayda at all", async () => {
|
it("grants the forwarder role to a foreign company whose owner has a passport and whose PoA is Fayda-verified", async () => {
|
||||||
|
const { service } = makeService({
|
||||||
|
profileTypes: [ProfileType.importer],
|
||||||
|
nationality: CompanyNationality.Foreign,
|
||||||
|
attributes: {
|
||||||
|
ownerPassportNumber: "P1234567",
|
||||||
|
...POA_VERIFIED,
|
||||||
|
},
|
||||||
|
files: [paper()],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createCompanyProfileForUser(
|
||||||
|
"user-1",
|
||||||
|
ProfileType.freightForwarder,
|
||||||
|
),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still requires a Fayda-verified PoA from a foreign company", async () => {
|
||||||
|
// The owner's credential is nationality-specific; the representative's is
|
||||||
|
// not. A PoA acts for the company inside Ethiopia whoever owns it, so a
|
||||||
|
// typed foreign name is not a representative the platform can accept.
|
||||||
const { service } = makeService({
|
const { service } = makeService({
|
||||||
profileTypes: [ProfileType.importer],
|
profileTypes: [ProfileType.importer],
|
||||||
nationality: CompanyNationality.Foreign,
|
nationality: CompanyNationality.Foreign,
|
||||||
@@ -385,7 +431,7 @@ describe("Ethiopian companies verify with Fayda; foreign companies verify identi
|
|||||||
"user-1",
|
"user-1",
|
||||||
ProfileType.freightForwarder,
|
ProfileType.freightForwarder,
|
||||||
),
|
),
|
||||||
).resolves.toBeDefined();
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
it("still requires the passport for a foreign owner who chose to verify with Fayda too", async () => {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ function makeService(overrides: Partial<Ctx> = {}) {
|
|||||||
findPendingByCompanyId: jest.fn(async () =>
|
findPendingByCompanyId: jest.fn(async () =>
|
||||||
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||||
),
|
),
|
||||||
|
findLatestOpenByCompanyId: jest.fn(async () =>
|
||||||
|
ctx.pendingSnapshot ? { id: "cr-1", snapshot: ctx.pendingSnapshot } : null,
|
||||||
|
),
|
||||||
findByCompanyId: jest.fn(async () => []),
|
findByCompanyId: jest.fn(async () => []),
|
||||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||||
id: "cr-1",
|
id: "cr-1",
|
||||||
|
|||||||
@@ -81,6 +81,28 @@ const POA_ATTRIBUTES = [
|
|||||||
"poaLocation",
|
"poaLocation",
|
||||||
"poaAddress",
|
"poaAddress",
|
||||||
] as const;
|
] as const;
|
||||||
|
/**
|
||||||
|
* Personnel an approved company maintains itself: its contact person, its
|
||||||
|
* general manager and its Power of Attorney. These name who to talk to, not
|
||||||
|
* what the company is allowed to do, so freezing the settings page until a
|
||||||
|
* reviewer gets to a new phone number costs more than it protects. They write
|
||||||
|
* straight to the live row even for an active company.
|
||||||
|
*
|
||||||
|
* The PoA's *delegation letter* is deliberately not here — the paper is the
|
||||||
|
* thing that actually evidences the delegation, so it still goes through
|
||||||
|
* review (see `uploadPoaDelegationLetter`), as does the owner's own identity.
|
||||||
|
*/
|
||||||
|
const SELF_SERVICE_ATTRIBUTES: readonly string[] = [
|
||||||
|
"contactPersonName",
|
||||||
|
"contactPersonPosition",
|
||||||
|
"contactPersonEmail",
|
||||||
|
"contactPersonPhone",
|
||||||
|
"contactVerifiedPhone",
|
||||||
|
"generalManagerName",
|
||||||
|
"generalManagerEmail",
|
||||||
|
"generalManagerPhone",
|
||||||
|
...POA_ATTRIBUTES,
|
||||||
|
];
|
||||||
/** Mandatory once the company operates as a freight forwarder. */
|
/** Mandatory once the company operates as a freight forwarder. */
|
||||||
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
const REQUIRED_POA_FIELDS: { key: string; label: string }[] = [
|
||||||
{ key: "poaName", label: "PoA name" },
|
{ key: "poaName", label: "PoA name" },
|
||||||
@@ -844,10 +866,11 @@ export class CompaniesService {
|
|||||||
*
|
*
|
||||||
* - Company not yet approved (onboarding) → write straight to the Company row,
|
* - Company not yet approved (onboarding) → write straight to the Company row,
|
||||||
* as before. The company/role pending→approve gate already covers first-run.
|
* as before. The company/role pending→approve gate already covers first-run.
|
||||||
* - Company already `active` → do NOT touch the live Company. Stage the edit in
|
* - Company already `active` → personnel details (`SELF_SERVICE_ATTRIBUTES`)
|
||||||
* a pending change request (merging into any open one) so a backoffice
|
* still write straight through; everything else does NOT touch the live
|
||||||
* reviewer can approve (apply) or reject (with a note). This locks the
|
* Company but is staged in a pending change request (merging into any open
|
||||||
* customer until the review resolves.
|
* one) so a backoffice reviewer can approve (apply) or reject (with a
|
||||||
|
* note). Only the staged half locks the customer until the review resolves.
|
||||||
*/
|
*/
|
||||||
async updateProfile(
|
async updateProfile(
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -882,9 +905,37 @@ export class CompaniesService {
|
|||||||
return new ProfileResponseDto(profile, updated);
|
return new ProfileResponseDto(profile, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Approved company: stage the change for review, leaving the live row intact.
|
// Approved company: personnel details apply immediately, the rest is staged
|
||||||
|
// for review with the live row left intact.
|
||||||
await this.assertTinAvailable(company, dto.tin);
|
await this.assertTinAvailable(company, dto.tin);
|
||||||
const fields = this.pickDefined(dto);
|
const fields = this.pickDefined(dto);
|
||||||
|
const selfService: Record<string, any> = {};
|
||||||
|
const staged: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(fields)) {
|
||||||
|
if (SELF_SERVICE_ATTRIBUTES.includes(key)) selfService[key] = value;
|
||||||
|
else staged[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let live = company;
|
||||||
|
if (Object.keys(selfService).length > 0) {
|
||||||
|
live =
|
||||||
|
(await this.companiesRepo.update(
|
||||||
|
company.id,
|
||||||
|
this.mapProfileDtoToCompanyUpdates(company, selfService),
|
||||||
|
)) ?? company;
|
||||||
|
live.companyProfiles = company.companyProfiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(staged).length === 0) {
|
||||||
|
// Nothing a reviewer needs to see. Any request already open (a document
|
||||||
|
// upload, an owner verification) still surfaces so its banner survives —
|
||||||
|
// it just no longer gains fields it was never asked to review.
|
||||||
|
return new ProfileResponseDto(
|
||||||
|
profile,
|
||||||
|
live,
|
||||||
|
await this.changeRequestRepo.findLatestOpenByCompanyId(company.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
const existing = await this.changeRequestRepo.findPendingByCompanyId(
|
||||||
company.id,
|
company.id,
|
||||||
@@ -894,7 +945,7 @@ export class CompaniesService {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
request =
|
request =
|
||||||
(await this.changeRequestRepo.update(existing.id, {
|
(await this.changeRequestRepo.update(existing.id, {
|
||||||
snapshot: { ...(existing.snapshot ?? {}), ...fields },
|
snapshot: { ...(existing.snapshot ?? {}), ...staged },
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
note: null,
|
note: null,
|
||||||
@@ -910,7 +961,7 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
request = await this.changeRequestRepo.create({
|
request = await this.changeRequestRepo.create({
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
snapshot: fields,
|
snapshot: staged,
|
||||||
status: ChangeRequestStatus.Pending,
|
status: ChangeRequestStatus.Pending,
|
||||||
submittedBy: userId,
|
submittedBy: userId,
|
||||||
submittedAt: now,
|
submittedAt: now,
|
||||||
@@ -922,8 +973,9 @@ export class CompaniesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live company is unchanged; surface the pending state for the settings page.
|
// Only the personnel half (if any) landed; surface the pending state for
|
||||||
return new ProfileResponseDto(profile, company, request);
|
// the settings page.
|
||||||
|
return new ProfileResponseDto(profile, live, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List a company's change requests, newest first (backoffice review). */
|
/** List a company's change requests, newest first (backoffice review). */
|
||||||
@@ -1720,16 +1772,11 @@ export class CompaniesService {
|
|||||||
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
const poaProvided = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
// An Ethiopian company does not type its PoA details at all — they arrive
|
// No company types its PoA details — they arrive from the Fayda
|
||||||
// from the Fayda verification — so reporting them as missing fields would
|
// verification whatever the nationality — so reporting them as missing
|
||||||
// ask for something the form no longer offers. The identity block below
|
// fields would ask for something no form offers. The identity block below
|
||||||
// reports "verify your PoA" instead.
|
// reports "verify your PoA" instead.
|
||||||
const missingPoaFields =
|
const missingPoaFields: typeof REQUIRED_POA_FIELDS = [];
|
||||||
poaRequired && !identity.faydaRequired
|
|
||||||
? REQUIRED_POA_FIELDS.filter(
|
|
||||||
(f) => !(company.attributes?.[f.key] as string | undefined)?.trim(),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const delegation = await this.getPoaDelegationState(company.id);
|
const delegation = await this.getPoaDelegationState(company.id);
|
||||||
const delegationDue = poaRequired || poaProvided;
|
const delegationDue = poaRequired || poaProvided;
|
||||||
const missingDelegation = delegationDue && !delegation.onFile;
|
const missingDelegation = delegationDue && !delegation.onFile;
|
||||||
@@ -1754,9 +1801,7 @@ export class CompaniesService {
|
|||||||
...(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"]
|
||||||
: []),
|
: []),
|
||||||
...(identity.faydaRequired &&
|
...((poaRequired || poaProvided) && !identity.poa.verified
|
||||||
(poaRequired || poaProvided) &&
|
|
||||||
!identity.poa.verified
|
|
||||||
? ["Verify your Power of Attorney's identity with Fayda"]
|
? ["Verify your Power of Attorney's identity with Fayda"]
|
||||||
: []),
|
: []),
|
||||||
...(identity.passportRequired && !identity.owner.passportNumber
|
...(identity.passportRequired && !identity.owner.passportNumber
|
||||||
@@ -1768,26 +1813,20 @@ export class CompaniesService {
|
|||||||
// fields, required documents, one license per operational profile, and the
|
// fields, required documents, one license per operational profile, and the
|
||||||
// PoA details/paper whenever those are mandatory.
|
// PoA details/paper whenever those are mandatory.
|
||||||
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
const requiredDocCount = documents.filter((d) => d.isRequired).length;
|
||||||
const poaItemCount =
|
const poaItemCount = delegationDue ? 1 : 0;
|
||||||
(poaRequired && !identity.faydaRequired
|
|
||||||
? REQUIRED_POA_FIELDS.length
|
|
||||||
: 0) + (delegationDue ? 1 : 0);
|
|
||||||
// One item per identity credential the company has to prove: the owner
|
// One item per identity credential the company has to prove: the owner
|
||||||
// always (Fayda for Ethiopian, passport for foreign), the PoA once there
|
// always (Fayda for Ethiopian, passport for foreign), plus the PoA once
|
||||||
// is one and Fayda is what's mandatory here.
|
// there is one — that one is Fayda whatever the nationality.
|
||||||
const identityItemCount = identity.faydaRequired
|
const ownerCredentialDue =
|
||||||
? delegationDue
|
identity.faydaRequired || identity.passportRequired;
|
||||||
? 2
|
const ownerCredentialProven = identity.faydaRequired
|
||||||
: 1
|
? identity.owner.verified
|
||||||
: identity.passportRequired
|
: Boolean(identity.owner.passportNumber);
|
||||||
? 1
|
const identityItemCount =
|
||||||
: 0;
|
(ownerCredentialDue ? 1 : 0) + (delegationDue ? 1 : 0);
|
||||||
const missingIdentityCount = identity.faydaRequired
|
const missingIdentityCount =
|
||||||
? (identity.owner.verified ? 0 : 1) +
|
(ownerCredentialDue && !ownerCredentialProven ? 1 : 0) +
|
||||||
(delegationDue && !identity.poa.verified ? 1 : 0)
|
(delegationDue && !identity.poa.verified ? 1 : 0);
|
||||||
: identity.passportRequired && !identity.owner.passportNumber
|
|
||||||
? 1
|
|
||||||
: 0;
|
|
||||||
const total =
|
const total =
|
||||||
requiredInfo.length +
|
requiredInfo.length +
|
||||||
requiredDocCount +
|
requiredDocCount +
|
||||||
@@ -2453,11 +2492,13 @@ export class CompaniesService {
|
|||||||
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
...(result.address ? { [`${prefix}Address`]: result.address } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// An approved company's profile edits are staged for backoffice review, and
|
// An approved company's *owner* is its identity proof, so re-verifying one
|
||||||
// swapping the person who can act for the company is exactly the kind of
|
// is staged for backoffice review rather than quietly rewriting a live
|
||||||
// edit that review exists for — so a verification lands the same way an
|
// record. The PoA is personnel — the company names its own representative,
|
||||||
// ordinary edit does, rather than quietly rewriting a live record.
|
// and the delegation letter backing them is what the reviewer sees — so a
|
||||||
if (company.status === CompanyStatus.Active) {
|
// PoA verification lands live, matching the typed PoA fields in
|
||||||
|
// `SELF_SERVICE_ATTRIBUTES`.
|
||||||
|
if (company.status === CompanyStatus.Active && dto.subject !== "poa") {
|
||||||
await this.stageIdentityChange(company, userId, identity);
|
await this.stageIdentityChange(company, userId, identity);
|
||||||
return this.getCompanyIdentityState(company);
|
return this.getCompanyIdentityState(company);
|
||||||
}
|
}
|
||||||
@@ -2586,21 +2627,23 @@ export class CompaniesService {
|
|||||||
): void {
|
): void {
|
||||||
const state = buildCompanyIdentityState(company);
|
const state = buildCompanyIdentityState(company);
|
||||||
|
|
||||||
|
// Only the owner's credential is nationality-specific: Fayda for an
|
||||||
|
// Ethiopian company, a typed passport number for a foreign one.
|
||||||
if (state.passportRequired) {
|
if (state.passportRequired) {
|
||||||
if (!state.owner.passportNumber) {
|
if (!state.owner.passportNumber) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Add the company owner's passport number before continuing.",
|
"Add the company owner's passport number before continuing.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
} else if (!state.owner.verified) {
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.owner.verified) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Verify the company owner's identity with Fayda before continuing.",
|
"Verify the company owner's identity with Fayda before continuing.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The representative is not. A PoA acts for the company inside Ethiopia
|
||||||
|
// whoever owns it, so they are always an Ethiopian holding a Fayda ID —
|
||||||
|
// a foreign company nominates one rather than typing a name.
|
||||||
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
const poaNamed = POA_ATTRIBUTES.some((k) =>
|
||||||
(company.attributes?.[k] as string | undefined)?.trim(),
|
(company.attributes?.[k] as string | undefined)?.trim(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -144,9 +144,14 @@ export function buildCompanyIdentityState(
|
|||||||
(p) => p.type === ProfileType.freightForwarder,
|
(p) => p.type === ProfileType.freightForwarder,
|
||||||
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
) || POA_KEYS.some((k) => (attrs[k] as string | undefined)?.trim());
|
||||||
|
|
||||||
const complete = faydaRequired
|
// Only the *owner's* credential is nationality-specific. A Power of Attorney
|
||||||
? owner.verified && (!poaDue || poa.verified)
|
// acts for the company inside Ethiopia whoever owns it, so the PoA is always
|
||||||
|
// proven with Fayda — a foreign company nominates a representative who holds
|
||||||
|
// one rather than typing a name nothing backs.
|
||||||
|
const ownerProven = faydaRequired
|
||||||
|
? owner.verified
|
||||||
: !passportRequired || Boolean(owner.passportNumber);
|
: !passportRequired || Boolean(owner.passportNumber);
|
||||||
|
const complete = ownerProven && (!poaDue || poa.verified);
|
||||||
|
|
||||||
return { faydaRequired, passportRequired, owner, poa, complete };
|
return { faydaRequired, passportRequired, owner, poa, complete };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,9 +287,11 @@ export default function SettingsPage() {
|
|||||||
icon={<Clock size={18} />}
|
icon={<Clock size={18} />}
|
||||||
title="Changes submitted for review"
|
title="Changes submitted for review"
|
||||||
>
|
>
|
||||||
Your recent changes are awaiting administrator approval. Editing is
|
Your recent changes are awaiting administrator approval. Company
|
||||||
disabled until the review is complete — you'll be notified once it's
|
details and documents can't be edited until the review is complete —
|
||||||
approved or if any changes are requested.
|
you'll be notified once it's approved or if any changes are
|
||||||
|
requested. Your contact person, general manager and Power of
|
||||||
|
Attorney stay editable.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{reviewStatus === "rejected" && (
|
{reviewStatus === "rejected" && (
|
||||||
@@ -358,9 +360,17 @@ export default function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
{/* While a change request is pending, every panel's inputs + submit
|
{/* While a change request is pending, the reviewed panels' inputs +
|
||||||
buttons are disabled via the native fieldset; tab switching stays
|
submit buttons are disabled via the native fieldset; tab switching
|
||||||
enabled so the customer can still review what they submitted. */}
|
stays enabled so the customer can still review what they
|
||||||
|
submitted.
|
||||||
|
|
||||||
|
Personnel panels below (contact person, general manager, Power of
|
||||||
|
Attorney) are deliberately outside the lock: the API applies those
|
||||||
|
edits live rather than staging them, so locking them here would
|
||||||
|
re-impose the approval wait the API no longer does. The PoA's
|
||||||
|
delegation letter is still reviewed — that lock lives on the file
|
||||||
|
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} />
|
||||||
@@ -368,19 +378,13 @@ export default function SettingsPage() {
|
|||||||
<OperationalServicesCard profile={profile} />
|
<OperationalServicesCard profile={profile} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="contact">
|
<Tabs.Panel value="contact">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabContactPerson profile={profile} mode="edit" />
|
||||||
<TabContactPerson profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="gm">
|
<Tabs.Panel value="gm">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabGeneralManager profile={profile} mode="edit" />
|
||||||
<TabGeneralManager profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="poa">
|
<Tabs.Panel value="poa">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
|
||||||
</Fieldset>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="documents">
|
<Tabs.Panel value="documents">
|
||||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import {
|
|||||||
buildOnboardingSchema,
|
buildOnboardingSchema,
|
||||||
type CompanyStep,
|
type CompanyStep,
|
||||||
type FormData,
|
type FormData,
|
||||||
hasPoaDetails,
|
|
||||||
POA_DELEGATION_FILE_KEY,
|
POA_DELEGATION_FILE_KEY,
|
||||||
stepFields,
|
stepFields,
|
||||||
} from "./companyProfileForm/schema";
|
} from "./companyProfileForm/schema";
|
||||||
@@ -191,11 +190,7 @@ export default function CompanyProfileForm({
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
buildOnboardingSchema(
|
buildOnboardingSchema(identity?.passportRequired === true),
|
||||||
requirePoa,
|
|
||||||
verifiedIdentity,
|
|
||||||
identity?.passportRequired === true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
companyName: "",
|
companyName: "",
|
||||||
@@ -321,7 +316,6 @@ export default function CompanyProfileForm({
|
|||||||
// them and re-enables editing.
|
// them and re-enables editing.
|
||||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
|
||||||
|
|
||||||
// General Manager source. The company step's email/phone are seeded from
|
// 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
|
// eTrade (and the account email) but stay editable, so the link reads the
|
||||||
@@ -367,9 +361,6 @@ export default function CompanyProfileForm({
|
|||||||
const gmName = watch("generalManagerName");
|
const gmName = watch("generalManagerName");
|
||||||
const gmEmail = watch("generalManagerEmail");
|
const gmEmail = watch("generalManagerEmail");
|
||||||
const gmPhone = watch("generalManagerPhone");
|
const gmPhone = watch("generalManagerPhone");
|
||||||
const contactName = watch("contactPersonName");
|
|
||||||
const contactEmail = watch("contactPersonEmail");
|
|
||||||
const contactPhone = watch("contactPersonPhone");
|
|
||||||
|
|
||||||
// While linked, mirror the source values into the (disabled) target fields so
|
// While linked, mirror the source values into the (disabled) target fields so
|
||||||
// the copy stays current even if the user goes back and edits the source.
|
// the copy stays current even if the user goes back and edits the source.
|
||||||
@@ -381,26 +372,6 @@ export default function CompanyProfileForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||||
|
|
||||||
// The contact-person step has no address of its own, so the linked PoA takes
|
|
||||||
// the company's composed address. poaLocation (the city) stays typed on the
|
|
||||||
// PoA step — the company step no longer has a location field to mirror.
|
|
||||||
const companyAddress = watch("companyAddress");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!poaSameAsContact) return;
|
|
||||||
setValue("poaName", contactName ?? "");
|
|
||||||
setValue("poaEmail", contactEmail ?? "");
|
|
||||||
setValue("poaPhone", contactPhone ?? "");
|
|
||||||
setValue("poaAddress", companyAddress ?? "");
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
|
||||||
poaSameAsContact,
|
|
||||||
contactName,
|
|
||||||
contactEmail,
|
|
||||||
contactPhone,
|
|
||||||
companyAddress,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const toggleContactSameAsGm = (checked: boolean) => {
|
const toggleContactSameAsGm = (checked: boolean) => {
|
||||||
setContactSameAsGm(checked);
|
setContactSameAsGm(checked);
|
||||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||||
@@ -411,17 +382,6 @@ export default function CompanyProfileForm({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePoaSameAsContact = (checked: boolean) => {
|
|
||||||
setPoaSameAsContact(checked);
|
|
||||||
if (!checked) {
|
|
||||||
setValue("poaName", "");
|
|
||||||
setValue("poaEmail", "");
|
|
||||||
setValue("poaPhone", "");
|
|
||||||
setValue("poaLocation", "");
|
|
||||||
setValue("poaAddress", "");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// The DARS delegation paper ships in the same nationality document set as the
|
// The DARS delegation paper ships in the same nationality document set as the
|
||||||
// rest (the API guarantees it is there), but belongs on the PoA step next to
|
// rest (the API guarantees it is there), but belongs on the PoA step next to
|
||||||
// the details it evidences — so it's split out here and the Documents step
|
// the details it evidences — so it's split out here and the Documents step
|
||||||
@@ -551,7 +511,9 @@ export default function CompanyProfileForm({
|
|||||||
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
// for a freight forwarder, whose PoA itself is mandatory. The API enforces
|
||||||
// the same rule on save, so skipping it here only costs the customer a
|
// the same rule on save, so skipping it here only costs the customer a
|
||||||
// round-trip.
|
// round-trip.
|
||||||
const poaProvided = hasPoaDetails(watch());
|
// A PoA exists exactly when one has been verified — the details are the
|
||||||
|
// verification's output, so there is nothing else that could stand for one.
|
||||||
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
const delegationRequired = requirePoa || poaProvided;
|
const delegationRequired = requirePoa || poaProvided;
|
||||||
const delegationPresent =
|
const delegationPresent =
|
||||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||||
@@ -638,12 +600,7 @@ export default function CompanyProfileForm({
|
|||||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (step === "poa" && requirePoa && !identity?.poa.verified) {
|
||||||
step === "poa" &&
|
|
||||||
verifiedIdentity &&
|
|
||||||
requirePoa &&
|
|
||||||
!identity?.poa.verified
|
|
||||||
) {
|
|
||||||
setSaveError(
|
setSaveError(
|
||||||
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
"Freight forwarders act on other companies' behalf, so the Power of Attorney's identity must be verified with Fayda.",
|
||||||
);
|
);
|
||||||
@@ -876,71 +833,27 @@ export default function CompanyProfileForm({
|
|||||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
? "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."}
|
: "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>
|
</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 && (
|
{identity && (
|
||||||
<FaydaVerifyPanel
|
<FaydaVerifyPanel
|
||||||
subject="poa"
|
subject="poa"
|
||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={identity.faydaRequired}
|
required={requirePoa}
|
||||||
onVerified={() => onIdentityChange?.()}
|
onVerified={() => onIdentityChange?.()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!verifiedIdentity && watch("contactPersonName") && (
|
|
||||||
<LinkCheckboxCard
|
|
||||||
checked={poaSameAsContact}
|
|
||||||
onToggle={togglePoaSameAsContact}
|
|
||||||
title="Same as contact person"
|
|
||||||
description="Reuse the contact person's name, email and phone, plus the company's location and address. Uncheck to enter different details."
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!verifiedIdentity && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Name"
|
|
||||||
placeholder="Authorized Representative Name"
|
|
||||||
error={errors.poaName?.message}
|
|
||||||
{...register("poaName")}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="PoA Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="poa@company.com"
|
|
||||||
error={errors.poaEmail?.message}
|
|
||||||
{...register("poaEmail")}
|
|
||||||
/>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="poaPhone"
|
|
||||||
label="PoA Phone"
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
<SimpleGrid cols={2} spacing="md">
|
|
||||||
<TextInput
|
|
||||||
label="PoA Location"
|
|
||||||
placeholder="City, Country"
|
|
||||||
error={errors.poaLocation?.message}
|
|
||||||
{...register("poaLocation")}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Address"
|
|
||||||
placeholder="Full Address"
|
|
||||||
error={errors.poaAddress?.message}
|
|
||||||
{...register("poaAddress")}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{/* The city is the one field the Fayda address claim does not
|
{/* The city is the one field the Fayda address claim does not
|
||||||
reliably decompose into, so it stays typed either way. */}
|
reliably decompose into, so it stays typed. */}
|
||||||
{verifiedIdentity && (
|
<TextInput
|
||||||
<TextInput
|
label="PoA Location"
|
||||||
label="PoA Location"
|
placeholder="City, Country"
|
||||||
placeholder="City, Country"
|
error={errors.poaLocation?.message}
|
||||||
error={errors.poaLocation?.message}
|
{...register("poaLocation")}
|
||||||
{...register("poaLocation")}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{poaDocumentSetting && (
|
{poaDocumentSetting && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -37,10 +37,8 @@ export function buildPayload(
|
|||||||
generalManagerName: data.generalManagerName,
|
generalManagerName: data.generalManagerName,
|
||||||
generalManagerEmail: data.generalManagerEmail,
|
generalManagerEmail: data.generalManagerEmail,
|
||||||
generalManagerPhone: data.generalManagerPhone,
|
generalManagerPhone: data.generalManagerPhone,
|
||||||
poaName: data.poaName || undefined,
|
// The representative's own details are written by their Fayda
|
||||||
poaPhone: data.poaPhone || undefined,
|
// verification, so the city is all the form has to send.
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
poaLocation: data.poaLocation || undefined,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -88,13 +86,7 @@ export function stepPayload(
|
|||||||
contactPersonPhone: d.contactPersonPhone,
|
contactPersonPhone: d.contactPersonPhone,
|
||||||
};
|
};
|
||||||
case "poa":
|
case "poa":
|
||||||
return {
|
return { poaLocation: d.poaLocation || undefined };
|
||||||
poaName: d.poaName || undefined,
|
|
||||||
poaPhone: d.poaPhone || undefined,
|
|
||||||
poaEmail: d.poaEmail || undefined,
|
|
||||||
poaLocation: d.poaLocation || undefined,
|
|
||||||
poaAddress: d.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
default:
|
default:
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,58 +92,28 @@ 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";
|
||||||
|
|
||||||
export const POA_FIELDS = [
|
|
||||||
"poaName",
|
|
||||||
"poaPhone",
|
|
||||||
"poaEmail",
|
|
||||||
"poaLocation",
|
|
||||||
"poaAddress",
|
|
||||||
] as const satisfies readonly (keyof FormData)[];
|
|
||||||
|
|
||||||
/** True once the customer has entered any Power of Attorney detail. */
|
|
||||||
export const hasPoaDetails = (d: Partial<FormData>) =>
|
|
||||||
POA_FIELDS.some((f) => d[f]?.trim());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A freight forwarder acts on other companies' behalf, so its PoA is mandatory
|
* The PoA's identifying fields are never typed — they come from the Fayda
|
||||||
* rather than optional. Everyone else keeps the optional PoA — but once they
|
* verification, whatever the company's nationality — so nothing here requires
|
||||||
* start filling it in, the identifying fields have to be complete (the
|
* them. A freight forwarder's mandatory PoA is gated on the verification
|
||||||
* delegation-letter upload is enforced alongside this, in CompanyProfileForm,
|
* itself, and its delegation letter alongside it, both in CompanyProfileForm
|
||||||
* since files live outside the form state).
|
* (files live outside form state).
|
||||||
|
*
|
||||||
|
* That leaves the owner's passport number as the only conditional field.
|
||||||
*/
|
*/
|
||||||
export function buildOnboardingSchema(
|
export function buildOnboardingSchema(
|
||||||
requirePoa: boolean,
|
|
||||||
/**
|
|
||||||
* True when the PoA's identity fields come from a Fayda verification rather
|
|
||||||
* than the form (Ethiopian companies). Requiring them here would fail
|
|
||||||
* validation against inputs the step no longer renders — the verification
|
|
||||||
* itself is what the step gates on instead.
|
|
||||||
*/
|
|
||||||
faydaOwnedPoa = false,
|
|
||||||
/** True for a foreign company: the owner's passport number is mandatory. */
|
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||||
passportRequired = false,
|
passportRequired = false,
|
||||||
) {
|
) {
|
||||||
const poaRequired = requirePoa && !faydaOwnedPoa;
|
if (!passportRequired) return onboardingSchema;
|
||||||
if (!poaRequired && !passportRequired) return onboardingSchema;
|
|
||||||
return onboardingSchema.superRefine((d, ctx) => {
|
return onboardingSchema.superRefine((d, ctx) => {
|
||||||
const required: [keyof FormData, string][] = [];
|
if (!d.ownerPassportNumber?.trim()) {
|
||||||
if (poaRequired) {
|
ctx.addIssue({
|
||||||
required.push(
|
code: z.ZodIssueCode.custom,
|
||||||
["poaName", "PoA name is required for freight forwarders"],
|
path: ["ownerPassportNumber"],
|
||||||
["poaEmail", "PoA email is required for freight forwarders"],
|
message: "The owner's passport number is required",
|
||||||
["poaPhone", "PoA phone is required for freight forwarders"],
|
});
|
||||||
);
|
|
||||||
}
|
|
||||||
if (passportRequired) {
|
|
||||||
required.push([
|
|
||||||
"ownerPassportNumber",
|
|
||||||
"The owner's passport number is required",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
for (const [path, message] of required) {
|
|
||||||
if (!d[path]?.trim()) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [path], message });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -180,7 +150,7 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
|||||||
"contactPersonEmail",
|
"contactPersonEmail",
|
||||||
"contactPersonPhone",
|
"contactPersonPhone",
|
||||||
],
|
],
|
||||||
poa: [...POA_FIELDS],
|
poa: ["poaLocation"],
|
||||||
documents: [],
|
documents: [],
|
||||||
additional: [],
|
additional: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,20 +39,15 @@ import {
|
|||||||
type LicenseFile,
|
type LicenseFile,
|
||||||
type LicenseFileStatus,
|
type LicenseFileStatus,
|
||||||
} from "@/services/companies.service";
|
} from "@/services/companies.service";
|
||||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
|
||||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||||
import { verifaydaService } from "@/services/verifayda.service";
|
import { verifaydaService } from "@/services/verifayda.service";
|
||||||
import type { ProfileResponse } from "@/types/profile";
|
import type { ProfileResponse } from "@/types/profile";
|
||||||
|
|
||||||
|
// The representative's name, email, phone and address all come from their
|
||||||
|
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
|
||||||
|
// is the only detail this form owns.
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
poaName: z.string().optional(),
|
|
||||||
poaEmail: z.string().optional(),
|
|
||||||
poaPhone: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
|
||||||
poaLocation: z.string().optional(),
|
poaLocation: z.string().optional(),
|
||||||
poaAddress: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
@@ -98,22 +93,15 @@ export default function TabPowerOfAttorney({
|
|||||||
const { view, viewer } = useFileViewer();
|
const { view, viewer } = useFileViewer();
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const defaultValues = useMemo((): FormData => {
|
const defaultValues = useMemo(
|
||||||
return {
|
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||||
poaName: profile.poaName ?? "",
|
[profile],
|
||||||
poaEmail: profile.poaEmail ?? "",
|
);
|
||||||
poaPhone: profile.poaPhone ?? "",
|
|
||||||
poaLocation: profile.poaLocation ?? "",
|
|
||||||
poaAddress: profile.poaAddress ?? "",
|
|
||||||
};
|
|
||||||
}, [profile]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
control,
|
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
reset,
|
reset,
|
||||||
watch,
|
|
||||||
formState: { errors, isDirty },
|
formState: { errors, isDirty },
|
||||||
} = useForm<FormData>({
|
} = useForm<FormData>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
@@ -123,10 +111,10 @@ export default function TabPowerOfAttorney({
|
|||||||
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
const letterQuery = useQuery(api.companies.poaDelegation.queryOptions({}));
|
||||||
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
const letters = useMemo(() => letterQuery.data ?? [], [letterQuery.data]);
|
||||||
|
|
||||||
// The letter is staged locally, not uploaded on pick. Uploading immediately
|
// The letter is staged locally, not uploaded on pick: the paper is the one
|
||||||
// would open a change request, which locks the whole settings page (see
|
// thing here that still goes to a reviewer, so picking it must not open a
|
||||||
// SettingsPage's `locked` fieldset) before the text fields could be saved.
|
// change request before the customer has committed to the save. Save submits
|
||||||
// Save submits the file and the fields together, into one change request.
|
// the file and the fields together.
|
||||||
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
const [pickedFile, setPickedFile] = useState<File | null>(null);
|
||||||
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
const [removeIds, setRemoveIds] = useState<string[]>([]);
|
||||||
const [saveBlocked, setSaveBlocked] = useState(false);
|
const [saveBlocked, setSaveBlocked] = useState(false);
|
||||||
@@ -142,22 +130,12 @@ export default function TabPowerOfAttorney({
|
|||||||
const requirePoa = profile.companyProfiles.some(
|
const requirePoa = profile.companyProfiles.some(
|
||||||
(p) => p.type === "freight_forwarder",
|
(p) => p.type === "freight_forwarder",
|
||||||
);
|
);
|
||||||
// An Ethiopian company does not type its representative's details — they
|
// No company types its representative's details — they come from the Fayda
|
||||||
// come from the Fayda verification. A foreign company keeps the typed form:
|
// verification whatever the nationality, since a representative acts for the
|
||||||
// its representative may hold no Fayda ID.
|
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||||
|
// has been verified.
|
||||||
const identity = profile.identity;
|
const identity = profile.identity;
|
||||||
const verifiedIdentity = identity?.faydaRequired === true;
|
const poaProvided = identity?.poa.verified ?? false;
|
||||||
|
|
||||||
const poaValues = watch([
|
|
||||||
"poaName",
|
|
||||||
"poaEmail",
|
|
||||||
"poaPhone",
|
|
||||||
"poaLocation",
|
|
||||||
"poaAddress",
|
|
||||||
]);
|
|
||||||
const poaProvided = verifiedIdentity
|
|
||||||
? (identity?.poa.verified ?? false)
|
|
||||||
: poaValues.some((v) => v?.trim());
|
|
||||||
const letterRequired = requirePoa || poaProvided;
|
const letterRequired = requirePoa || poaProvided;
|
||||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||||
|
|
||||||
@@ -166,16 +144,8 @@ export default function TabPowerOfAttorney({
|
|||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (data: FormData) => {
|
mutationFn: async (data: FormData) => {
|
||||||
// Every identity field except the city is written by the verification, so
|
// Every identity field except the city is written by the verification, so
|
||||||
// an Ethiopian company only ever saves the paper and the location here.
|
// only the paper and the location are ever saved here.
|
||||||
const fields = verifiedIdentity
|
const fields = { poaLocation: data.poaLocation || undefined };
|
||||||
? { poaLocation: data.poaLocation || undefined }
|
|
||||||
: {
|
|
||||||
poaName: data.poaName || undefined,
|
|
||||||
poaPhone: data.poaPhone || undefined,
|
|
||||||
poaEmail: data.poaEmail || undefined,
|
|
||||||
poaLocation: data.poaLocation || undefined,
|
|
||||||
poaAddress: data.poaAddress || undefined,
|
|
||||||
};
|
|
||||||
// A fresh upload already stages the removal of every paper on file, so
|
// A fresh upload already stages the removal of every paper on file, so
|
||||||
// the explicit removals only need applying when no replacement was
|
// the explicit removals only need applying when no replacement was
|
||||||
// picked. Saving the details after it means the API sees the new paper.
|
// picked. Saving the details after it means the API sees the new paper.
|
||||||
@@ -281,12 +251,8 @@ export default function TabPowerOfAttorney({
|
|||||||
subject="poa"
|
subject="poa"
|
||||||
title="Power of Attorney"
|
title="Power of Attorney"
|
||||||
state={identity.poa}
|
state={identity.poa}
|
||||||
required={identity.faydaRequired}
|
required={requirePoa}
|
||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
pendingReview={Boolean(
|
|
||||||
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
|
||||||
?.faydaIdentity?.poaFaydaSub,
|
|
||||||
)}
|
|
||||||
onVerified={() => {
|
onVerified={() => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: api.companies.getProfile.queryKey(),
|
queryKey: api.companies.getProfile.queryKey(),
|
||||||
@@ -300,39 +266,9 @@ export default function TabPowerOfAttorney({
|
|||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{/* Name, email, phone and address are written by the Fayda
|
{/* Name, email, phone and address are all written by the Fayda
|
||||||
verification for an Ethiopian company, so only the city — which
|
verification, so only the city — which the address claim does
|
||||||
the address claim does not reliably decompose into — is typed. */}
|
not reliably decompose into — is typed. */}
|
||||||
{!verifiedIdentity && (
|
|
||||||
<>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Full Name"
|
|
||||||
placeholder="Authorized Representative Name"
|
|
||||||
error={errors.poaName?.message}
|
|
||||||
{...register("poaName")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Grid>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Email"
|
|
||||||
type="email"
|
|
||||||
placeholder="poa@company.com"
|
|
||||||
error={errors.poaEmail?.message}
|
|
||||||
{...register("poaEmail")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<ControlledPhoneField
|
|
||||||
control={control}
|
|
||||||
name="poaPhone"
|
|
||||||
label="PoA Phone"
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
</Grid>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={6}>
|
<Grid.Col span={6}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -342,16 +278,6 @@ export default function TabPowerOfAttorney({
|
|||||||
{...register("poaLocation")}
|
{...register("poaLocation")}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
{!verifiedIdentity && (
|
|
||||||
<Grid.Col span={6}>
|
|
||||||
<TextInput
|
|
||||||
label="PoA Address"
|
|
||||||
placeholder="Full Address"
|
|
||||||
error={errors.poaAddress?.message}
|
|
||||||
{...register("poaAddress")}
|
|
||||||
/>
|
|
||||||
</Grid.Col>
|
|
||||||
)}
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
@@ -480,7 +406,10 @@ export default function TabPowerOfAttorney({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.reviewStatus === "pending" && (
|
{/* Keyed on the paper's own staged status, not the company's
|
||||||
|
review state: the details on this tab now apply live, so a
|
||||||
|
pending review is just as likely to be about something else. */}
|
||||||
|
{letters.some((f) => f.status !== "live") && (
|
||||||
<Group gap={6} c="edr-amber-text">
|
<Group gap={6} c="edr-amber-text">
|
||||||
<Clock size={13} />
|
<Clock size={13} />
|
||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
@@ -527,7 +456,6 @@ export default function TabPowerOfAttorney({
|
|||||||
</Group>
|
</Group>
|
||||||
<Group gap="md">
|
<Group gap="md">
|
||||||
{mode === "edit" &&
|
{mode === "edit" &&
|
||||||
verifiedIdentity &&
|
|
||||||
identity?.poa.verified &&
|
identity?.poa.verified &&
|
||||||
!requirePoa && (
|
!requirePoa && (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
Reference in New Issue
Block a user