mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
refactor(freight-portal): rebuild the onboarding wizard around owner and representation
Steps are now company -> owner -> representation -> contact -> documents; PersonnelStep and PoaStep are gone. - OwnerStep shows whoever the eTrade licence names, read-only with a provenance badge (SourcedField), and renders an input for every gap eTrade and Fayda left. It warns when the stored owner does not match eTrade. - RepresentationStep asks outright whether anyone holds power of attorney, then branches: "no" verifies the owner, "yes" collects the representative and the DARS delegation letter. Freight forwarders cannot answer "no". - Passport input appears only for a foreign company whose subject has not verified with Fayda. - Drop every same-as-owner copy-across and the auth-user fallbacks for the owner's email and phone. Re-picking a business licence now clears the eTrade owner prefill, since licences under one TIN can name different managers. - Settings: TabGeneralManager replaced by TabOwner; TabPowerOfAttorney reworked around poaDeclared.
This commit is contained in:
@@ -41,12 +41,17 @@ import type { UpdateProfilePayload } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Form steps rendered by CompanyProfileForm. */
|
||||
type FormStep = "company" | "personnel" | "contact" | "poa" | "documents";
|
||||
type FormStep =
|
||||
| "company"
|
||||
| "owner"
|
||||
| "representation"
|
||||
| "contact"
|
||||
| "documents";
|
||||
const FORM_STEPS: FormStep[] = [
|
||||
"company",
|
||||
"personnel",
|
||||
"owner",
|
||||
"representation",
|
||||
"contact",
|
||||
"poa",
|
||||
"documents",
|
||||
];
|
||||
|
||||
@@ -68,23 +73,25 @@ const STEP_META: Record<
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
description:
|
||||
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
|
||||
"Confirm your VAT number and we'll pull your registration straight from eTrade.",
|
||||
},
|
||||
personnel: {
|
||||
owner: {
|
||||
icon: <User size={20} />,
|
||||
title: "General Manager",
|
||||
description: "Who is the general manager of the company?",
|
||||
title: "Company Owner",
|
||||
description:
|
||||
"The person registered on your eTrade licence. We fill in what eTrade and Fayda gave us.",
|
||||
},
|
||||
representation: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Who Acts For You",
|
||||
description:
|
||||
"Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.",
|
||||
},
|
||||
contact: {
|
||||
icon: <UserCheck size={20} />,
|
||||
title: "Contact Person",
|
||||
description: "Who should we reach out to about this account?",
|
||||
},
|
||||
poa: {
|
||||
icon: <FileText size={20} />,
|
||||
title: "Power of Attorney",
|
||||
description: "Optionally add a representative with power of attorney.",
|
||||
},
|
||||
documents: {
|
||||
icon: <UploadCloud size={20} />,
|
||||
title: "Upload Documents",
|
||||
@@ -210,7 +217,7 @@ export default function OnboardingWizardDialog({
|
||||
}) => api.companies.startOnboarding.call(vars),
|
||||
onSuccess: async () => {
|
||||
// Nationality drives the server-resolved identity requirements (Fayda vs
|
||||
// passport), the document set and the GM/PoA copy — all read from
|
||||
// passport), the document set and the PoA copy — all read from
|
||||
// onboardingRequirements/profile. Re-entering role selection can change
|
||||
// it, so both must be refetched alongside getInfo or the form step would
|
||||
// keep rendering the previous nationality's requirements.
|
||||
@@ -414,15 +421,21 @@ export default function OnboardingWizardDialog({
|
||||
const requiredDocsMissing = requirementDocuments.some(
|
||||
(d) => d.isRequired && !d.uploaded,
|
||||
);
|
||||
// The PoA gets the same treatment: a resumed draft that predates the
|
||||
// delegation-letter requirement (or a forwarder whose PoA is blank) must land
|
||||
// back on the PoA step, where both the details and the letter are entered.
|
||||
const poaIncomplete = requirementsQuery.data?.poa?.complete === false;
|
||||
// The representation step gets the same treatment. An unanswered
|
||||
// power-of-attorney question, or a declared representative still missing
|
||||
// details or the DARS paper, must land the customer back on the step where
|
||||
// all of that is entered — including a draft that predates the question
|
||||
// existing at all, whose `declared` comes back null.
|
||||
const representationIncomplete =
|
||||
requirementsQuery.data?.poa?.declared == null ||
|
||||
requirementsQuery.data?.poa?.complete === false ||
|
||||
requirementsQuery.data?.identity?.identityProven === false;
|
||||
// Each unmet requirement lowers the ceiling; resume never moves forward.
|
||||
let ceiling = FORM_STEPS.length - 1;
|
||||
if (requiredDocsMissing)
|
||||
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
|
||||
if (poaIncomplete) ceiling = Math.min(ceiling, FORM_STEPS.indexOf("poa"));
|
||||
if (representationIncomplete)
|
||||
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation"));
|
||||
const effectiveResumeStep: FormStep =
|
||||
FORM_STEPS[
|
||||
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
|
||||
@@ -446,9 +459,9 @@ export default function OnboardingWizardDialog({
|
||||
onLicenseChange: setLicenseFiles,
|
||||
uploadedDocumentKeys,
|
||||
onUploadDocuments: handleUploadDocuments,
|
||||
// Fayda verification state for the owner and the PoA — the general manager
|
||||
// stays a plain typed role. Mandatory (Fayda) for an Ethiopian company;
|
||||
// a foreign one requires a typed passport number for the owner instead.
|
||||
// The company's single identity verification, and whose it is. Fayda is
|
||||
// mandatory for an Ethiopian company; a foreign one may instead type a
|
||||
// passport number for the same person.
|
||||
identity: requirementsQuery.data?.identity,
|
||||
onIdentityChange: () => {
|
||||
void profileQuery.refetch();
|
||||
|
||||
@@ -49,49 +49,46 @@ import TabAccount from "./settings/TabAccount";
|
||||
import TabCompanyProfile from "./settings/TabCompanyProfile";
|
||||
import TabContactPerson from "./settings/TabContactPerson";
|
||||
import TabDocuments from "./settings/TabDocuments";
|
||||
import TabGeneralManager from "./settings/TabGeneralManager";
|
||||
import TabOwner from "./settings/TabOwner";
|
||||
import TabPowerOfAttorney from "./settings/TabPowerOfAttorney";
|
||||
|
||||
type SettingsTab =
|
||||
| "account"
|
||||
| "company"
|
||||
| "contact"
|
||||
| "gm"
|
||||
| "owner"
|
||||
| "poa"
|
||||
| "documents";
|
||||
|
||||
/** A section is "incomplete" when its required fields aren't filled in yet. */
|
||||
function tabIncomplete(tabId: SettingsTab, profile: ProfileResponse): boolean {
|
||||
switch (tabId) {
|
||||
case "company": {
|
||||
// Identity proof lives here: the owner's Fayda verification for an
|
||||
// Ethiopian company, or the owner's typed passport number for a foreign
|
||||
// one.
|
||||
const identity = profile.identity;
|
||||
const identityIncomplete = identity
|
||||
? (identity.faydaRequired && !identity.owner.verified) ||
|
||||
(identity.passportRequired && !identity.owner.passportNumber)
|
||||
: false;
|
||||
return !profile.companyAddress || identityIncomplete;
|
||||
}
|
||||
case "company":
|
||||
return !profile.companyAddress;
|
||||
case "contact":
|
||||
return !profile.contactPersonName || !profile.contactPersonPhone;
|
||||
case "gm":
|
||||
// The GM is established through Fayda — verified in their own right or
|
||||
// declared the same person as the owner — so the identity answers this,
|
||||
// not the typed columns. A company that may still type them (foreign,
|
||||
// whose manager may hold no Fayda ID) is judged on those instead.
|
||||
if (profile.identity?.gm.verified) return false;
|
||||
if (profile.identity?.faydaRequired) return true;
|
||||
case "owner":
|
||||
// The owner is whoever the eTrade licence names. All three details are
|
||||
// required whatever supplied them, and the identity verification lives
|
||||
// on whichever person the PoA declaration points at — flagged here when
|
||||
// it is the owner and still unproven.
|
||||
return (
|
||||
!profile.generalManagerName ||
|
||||
!profile.generalManagerEmail ||
|
||||
!profile.generalManagerPhone
|
||||
!profile.ownerName ||
|
||||
!profile.ownerEmail ||
|
||||
!profile.ownerPhone ||
|
||||
(profile.identity?.subject === "owner" &&
|
||||
!profile.identity.identityProven)
|
||||
);
|
||||
case "poa":
|
||||
// Unanswered is itself incomplete — the answer decides whose identity is
|
||||
// verified — as is a declared representative who has not proved theirs.
|
||||
if (profile.identity?.poaDeclared == null) return true;
|
||||
return (
|
||||
profile.identity.subject === "poa" && !profile.identity.identityProven
|
||||
);
|
||||
case "account":
|
||||
// Account fields live on the IAM user, not the company profile, and are
|
||||
// always populated (signup requires them) — nothing to nag about here.
|
||||
case "poa":
|
||||
case "documents":
|
||||
return false;
|
||||
}
|
||||
@@ -101,7 +98,7 @@ const TABS: { id: SettingsTab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: "account", label: "Account", icon: <UserCog size={16} /> },
|
||||
{ id: "company", label: "Company", icon: <Building2 size={16} /> },
|
||||
{ id: "contact", label: "Contact Person", icon: <User size={16} /> },
|
||||
{ id: "gm", label: "General Manager", icon: <Briefcase size={16} /> },
|
||||
{ id: "owner", label: "Owner", icon: <Briefcase size={16} /> },
|
||||
{ id: "poa", label: "Power of Attorney", icon: <UserCheck size={16} /> },
|
||||
{ id: "documents", label: "Documents", icon: <FileCheck size={16} /> },
|
||||
];
|
||||
@@ -405,8 +402,8 @@ export default function SettingsPage() {
|
||||
<Tabs.Panel value="contact">
|
||||
<TabContactPerson profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="gm">
|
||||
<TabGeneralManager profile={profile} mode="edit" />
|
||||
<Tabs.Panel value="owner">
|
||||
<TabOwner profile={profile} mode="edit" />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="poa">
|
||||
<TabPowerOfAttorney profile={profile} mode="edit" />
|
||||
|
||||
@@ -24,18 +24,19 @@ import {
|
||||
import {
|
||||
buildPayload,
|
||||
firstPresent,
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
stepPayload,
|
||||
toFormValues,
|
||||
} from "./companyProfileForm/helpers";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
import type {
|
||||
CompanyIdentityState,
|
||||
PoaDeclaration,
|
||||
} from "@/services/verifayda.service";
|
||||
import CompanyInfoStep from "./companyProfileForm/steps/CompanyInfoStep";
|
||||
import PersonnelStep from "./companyProfileForm/steps/PersonnelStep";
|
||||
import OwnerStep from "./companyProfileForm/steps/OwnerStep";
|
||||
import ContactStep from "./companyProfileForm/steps/ContactStep";
|
||||
import PoaStep from "./companyProfileForm/steps/PoaStep";
|
||||
import RepresentationStep from "./companyProfileForm/steps/RepresentationStep";
|
||||
import DocumentsStep from "./companyProfileForm/steps/DocumentsStep";
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
@@ -96,7 +97,7 @@ export default function CompanyProfileForm({
|
||||
onUploadDocuments?: () => Promise<
|
||||
{ ok: true } | { ok: false; error: string }
|
||||
>;
|
||||
/** Fayda verification state for the owner and the PoA (undefined until loaded). */
|
||||
/** The company's single identity verification (undefined until loaded). */
|
||||
identity?: CompanyIdentityState;
|
||||
/**
|
||||
* Refetch the profile + requirements. Only the in-page identity actions need
|
||||
@@ -148,7 +149,7 @@ export default function CompanyProfileForm({
|
||||
|
||||
// Follow a parent-driven resume correction: if initialStep changes (the wizard
|
||||
// re-clamps it back once onboarding requirements load — e.g. a required
|
||||
// document is still missing, so it must not skip ahead to Business License),
|
||||
// document is still missing, so it must not skip ahead to the documents step),
|
||||
// adopt it, but only while the user hasn't started navigating themselves.
|
||||
const lastInitialStep = useRef(initialStep);
|
||||
useEffect(() => {
|
||||
@@ -174,16 +175,6 @@ export default function CompanyProfileForm({
|
||||
}),
|
||||
);
|
||||
|
||||
// A freight forwarder signs on other companies' behalf, so its Power of
|
||||
// Attorney (details + DARS delegation paper) is mandatory rather than optional.
|
||||
const requirePoa = (roleProfiles ?? []).some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// Fayda is an Ethiopian national ID: an Ethiopian company verifies its owner
|
||||
// and PoA instead of typing their details, a foreign one keeps the typed
|
||||
// forms (plus a mandatory owner passport number).
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
// Which fields the current step renders an input for and therefore requires.
|
||||
// Filled in further down (it depends on values this form owns), and read at
|
||||
// validation time rather than at render time — the resolver below runs on
|
||||
@@ -192,16 +183,15 @@ export default function CompanyProfileForm({
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: (values, context, options) =>
|
||||
zodResolver(
|
||||
buildOnboardingSchema(
|
||||
identity?.passportRequired === true,
|
||||
requiredKeysRef.current,
|
||||
),
|
||||
)(values, context, options),
|
||||
zodResolver(buildOnboardingSchema(requiredKeysRef.current))(
|
||||
values,
|
||||
context,
|
||||
options,
|
||||
),
|
||||
// `values` below re-seeds the form whenever the profile is refetched — and
|
||||
// an in-page identity action (ticking "same as owner") refetches it. Without
|
||||
// this, that reset silently throws away whatever the customer was part-way
|
||||
// through typing on the current step.
|
||||
// an in-page identity action (answering the PoA question) refetches it.
|
||||
// Without this, that reset silently throws away whatever the customer was
|
||||
// part-way through typing on the current step.
|
||||
resetOptions: { keepDirtyValues: true, keepErrors: true },
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
@@ -210,6 +200,7 @@ export default function CompanyProfileForm({
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
ownerPassportNumber: "",
|
||||
poaPassportNumber: "",
|
||||
licenceNumber: "",
|
||||
statusDescription: "",
|
||||
dateRegistered: "",
|
||||
@@ -225,9 +216,9 @@ export default function CompanyProfileForm({
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
contactPersonPhone: "",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
ownerName: "",
|
||||
ownerEmail: "",
|
||||
ownerPhone: "",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaAddress: "",
|
||||
@@ -273,8 +264,9 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [region, zone, woreda, kebele, houseNo]);
|
||||
|
||||
// The business owner/manager pulled from eTrade — powers "Use owner as
|
||||
// manager" on the General Manager step. Null until a TIN lookup succeeds.
|
||||
// The manager eTrade lists for this licence. This is the person the owner
|
||||
// step is about — "owner" here means whoever the licence names, and the
|
||||
// backoffice checks the stored owner against exactly this.
|
||||
const [etradeOwner, setEtradeOwner] = useState<{
|
||||
name: string;
|
||||
phone: string;
|
||||
@@ -302,31 +294,45 @@ export default function CompanyProfileForm({
|
||||
setValue("woreda", data.woreda, dirty);
|
||||
setValue("kebele", data.kebele, dirty);
|
||||
setValue("houseNo", data.houseNo, dirty);
|
||||
// companyAddress is composed reactively from the address fields below, so
|
||||
// setting region/zone/woreda/kebele/houseNo above is enough — no need to
|
||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||
// account), not set directly here.
|
||||
// companyAddress is composed reactively from the address fields above.
|
||||
// etradePhone is the raw number eTrade returned for this TIN — kept as its
|
||||
// own field (distinct from companyPhone, which prefers the Fayda-verified
|
||||
// owner's phone) so the backend's "matches eTrade's current record" check
|
||||
// always compares against what eTrade actually said, not the owner's phone.
|
||||
// own field (distinct from the owner's phone) so the backend's "matches
|
||||
// eTrade's current record" check always compares against what eTrade
|
||||
// actually said.
|
||||
setValue(
|
||||
"etradePhone",
|
||||
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||
dirty,
|
||||
);
|
||||
|
||||
setEtradeOwner({
|
||||
const owner = {
|
||||
name: data.managerName,
|
||||
phone: toEthiopianE164(
|
||||
data.managerPhone || data.regularPhone || data.mobilePhone,
|
||||
),
|
||||
});
|
||||
};
|
||||
setEtradeOwner(owner);
|
||||
|
||||
// Prefill the owner inputs rather than replacing them. The customer can
|
||||
// still correct a name eTrade transliterated oddly — and if they change it
|
||||
// to a different person, `identity.ownerMatchesEtrade` says so to both them
|
||||
// and the reviewer. Only fills what is empty: a value the customer already
|
||||
// typed (or a Fayda claim already stored) is not overwritten by a lookup.
|
||||
if (owner.name && !getValues("ownerName")?.trim()) {
|
||||
setValue("ownerName", owner.name, { shouldValidate: true, ...dirty });
|
||||
}
|
||||
if (owner.phone && !getValues("ownerPhone")?.trim()) {
|
||||
setValue("ownerPhone", owner.phone, { shouldValidate: true, ...dirty });
|
||||
}
|
||||
};
|
||||
|
||||
// TIN changed since the last successful lookup — the registration/address
|
||||
// fields it filled in describe the OLD TIN, not this one, so clear them
|
||||
// rather than leaving them stale on screen.
|
||||
//
|
||||
// The owner goes too: different licences under one TIN can list different
|
||||
// managers, so a prefill from the previous pick is someone else's name.
|
||||
// Only the prefill is cleared — a Fayda-verified owner is the API's to own.
|
||||
const handleETradeReset = () => {
|
||||
setValue("licenceNumber", "");
|
||||
setValue("statusDescription", "");
|
||||
@@ -340,273 +346,91 @@ export default function CompanyProfileForm({
|
||||
setValue("kebele", "");
|
||||
setValue("houseNo", "");
|
||||
setValue("etradePhone", "");
|
||||
if (!identity?.owner.verified) {
|
||||
if (getValues("ownerName") === etradeOwner?.name) setValue("ownerName", "");
|
||||
if (getValues("ownerPhone") === etradeOwner?.phone)
|
||||
setValue("ownerPhone", "");
|
||||
}
|
||||
setEtradeOwner(null);
|
||||
};
|
||||
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
// Seeded from the server so a resumed draft reopens with the declaration the
|
||||
// company already made, rather than an unticked box over a linked GM.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||
identity?.gmSameAsOwner ?? false,
|
||||
);
|
||||
// `identity` is undefined on the first render (the requirements query is still
|
||||
// in flight), so the initial state above freezes at `false` — adopt the
|
||||
// server's declaration the moment it lands, or a resumed draft shows an
|
||||
// unticked box over a GM that is linked server-side.
|
||||
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
|
||||
identity?.poaSameAsOwner ?? false,
|
||||
);
|
||||
const identityLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!identity || identityLoaded.current) return;
|
||||
identityLoaded.current = true;
|
||||
setGmSameAsOwner(identity.gmSameAsOwner);
|
||||
setPoaSameAsOwner(identity.poaSameAsOwner);
|
||||
}, [identity]);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
|
||||
// Where the owner's details come from when they are copied onto someone else
|
||||
// — the GM, or the representative. A Fayda-verified owner outranks eTrade's
|
||||
// registered owner: it's the higher-trust source, and the whole point of
|
||||
// proving identity is to stop trusting typed/looked-up data for this.
|
||||
const ownerSourceName = firstPresent(
|
||||
identity?.owner.name,
|
||||
etradeOwner?.name,
|
||||
user.name?.en,
|
||||
);
|
||||
|
||||
const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email);
|
||||
// Same reason as `derivedPhone`: this value is written into
|
||||
// `generalManagerPhone` / `poaPhone`, which the API validates with
|
||||
// `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
|
||||
const ownerSourcePhone = firstValidPhone(
|
||||
identity?.owner.phone,
|
||||
etradeOwner?.phone,
|
||||
user.phoneNumber,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
// A verified owner's identity is copied server-side and read back from
|
||||
// `identity.gm`; mirroring it into form fields here would send typed
|
||||
// values for something the API already owns.
|
||||
if (identity?.owner.verified) return;
|
||||
setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
|
||||
setValue("generalManagerPhone", ownerSourcePhone, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
|
||||
|
||||
// The representative's half of the same copy. A verified owner's identity is
|
||||
// copied server-side and read back from `identity.poa`, so only an owner
|
||||
// backed by a typed passport is mirrored into form fields here — the same
|
||||
// split the GM makes above, for the same reason.
|
||||
//
|
||||
// Only non-empty sources are written. A source the owner does not have is a
|
||||
// gap the step renders an input for (see `poaGaps`), and this effect re-runs
|
||||
// whenever any *other* source changes — so blanking here would wipe what the
|
||||
// customer is typing into that input the moment an eTrade lookup lands.
|
||||
useEffect(() => {
|
||||
if (!poaSameAsOwner || identity?.owner.verified) return;
|
||||
if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true });
|
||||
if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true });
|
||||
if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
|
||||
|
||||
/**
|
||||
* "Same as owner" has two meanings depending on what backs the owner.
|
||||
* Answer the power-of-attorney question.
|
||||
*
|
||||
* A Fayda-verified owner is a proven identity, so the declaration is made
|
||||
* server-side: the API copies that identity onto the GM and records what it
|
||||
* did. Anything typed here would arrive wearing a verified badge it hadn't
|
||||
* earned, which is exactly what the verification exists to prevent.
|
||||
*
|
||||
* A foreign company's owner is backed by a typed passport instead, so there
|
||||
* is nothing proven to copy — that stays the local field-mirroring it has
|
||||
* always been.
|
||||
* Persisted server-side rather than held in form state: the answer decides
|
||||
* whose identity the API gates on, and answering "no" tears down any
|
||||
* representative already recorded (details, verification and DARS paper
|
||||
* together) — none of which the form could do on its own.
|
||||
*/
|
||||
const [gmLinkPending, setGmLinkPending] = useState(false);
|
||||
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (!identity?.owner.verified) {
|
||||
if (!checked) {
|
||||
setValue("generalManagerName", "");
|
||||
setValue("generalManagerEmail", "");
|
||||
setValue("generalManagerPhone", "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setGmLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||
else await verifaydaService.clearGmIdentity();
|
||||
onIdentityChange?.();
|
||||
} catch (err) {
|
||||
setGmSameAsOwner(!checked);
|
||||
setSaveError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error
|
||||
? err.message
|
||||
: "Could not update the general manager"),
|
||||
);
|
||||
} finally {
|
||||
setGmLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The representative is the owner. Unlike the GM's card this always goes to
|
||||
* the API, whichever backs the owner: the declaration itself is what waives
|
||||
* the DARS delegation paper, so it has to be recorded server-side even when
|
||||
* there is no proven identity to copy and the details are mirrored locally.
|
||||
*/
|
||||
const [poaLinkPending, setPoaLinkPending] = useState(false);
|
||||
const togglePoaSameAsOwner = async (checked: boolean) => {
|
||||
const [declarePending, setDeclarePending] = useState(false);
|
||||
const handleDeclare = async (declared: PoaDeclaration) => {
|
||||
setSaveError(null);
|
||||
setPoaSameAsOwner(checked);
|
||||
setPoaLinkPending(true);
|
||||
setDeclarePending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setPoaSameAsOwner();
|
||||
else {
|
||||
await verifaydaService.clearPoaSameAsOwner();
|
||||
// Only the locally mirrored values are ours to clear; a copied identity
|
||||
// is cleared by the call above.
|
||||
if (!identity?.owner.verified) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
}
|
||||
await verifaydaService.setPoaDeclared(declared);
|
||||
if (declared === "no") {
|
||||
// The paper is deleted server-side with the representative; a copy
|
||||
// still sitting in the picker would be re-uploaded on the next step.
|
||||
setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null });
|
||||
}
|
||||
onIdentityChange?.();
|
||||
} catch (err) {
|
||||
setPoaSameAsOwner(!checked);
|
||||
setSaveError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error
|
||||
? err.message
|
||||
: "Could not update the Power of Attorney"),
|
||||
);
|
||||
} finally {
|
||||
setPoaLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Where the GM's details come from depends on how they were established: a
|
||||
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
||||
// and only a company that may still type them falls back to form state.
|
||||
// A verified GM's identity wins, but Fayda's email and phone claims are
|
||||
// optional: what the verification did not supply is typed on this step, and
|
||||
// the API deliberately does not read those back onto the identity (they are
|
||||
// not proven), so the form value is the only place they exist.
|
||||
const gmVerified = identity?.gm.verified ?? false;
|
||||
const gmName = gmVerified
|
||||
? firstPresent(identity?.gm.name, watch("generalManagerName"))
|
||||
: watch("generalManagerName");
|
||||
const gmEmail = gmVerified
|
||||
? firstPresent(identity?.gm.email, watch("generalManagerEmail"))
|
||||
: watch("generalManagerEmail");
|
||||
const gmPhone = gmVerified
|
||||
? firstPresent(identity?.gm.phone, watch("generalManagerPhone"))
|
||||
: watch("generalManagerPhone");
|
||||
|
||||
/**
|
||||
* Whether the GM has been established at all — by verification, by the
|
||||
* "same as owner" declaration, or (only where Fayda is optional) by typing.
|
||||
* Fayda is an Ethiopian national ID, so a foreign company's GM may hold none.
|
||||
*/
|
||||
// Matches what the API actually demands (`REQUIRED_COMPANY_INFO`): a name and
|
||||
// a phone. The email is collected but optional — a manager proved through
|
||||
// Fayda may have no email claim, and the notify resolver no longer needs one.
|
||||
const gmTyped = Boolean(
|
||||
watch("generalManagerName")?.trim() &&
|
||||
watch("generalManagerPhone")?.trim(),
|
||||
);
|
||||
const gmEstablished =
|
||||
gmVerified || (identity ? !identity.faydaRequired && gmTyped : gmTyped);
|
||||
|
||||
/**
|
||||
* Same rule for the representative: verified, or entered where Fayda is
|
||||
* optional.
|
||||
*
|
||||
* Matches the API's own rule (`REQUIRED_POA_FIELDS`): a typed representative
|
||||
* counts once they have a name, an email and a phone. The step now renders
|
||||
* inputs for all three, so this is something the customer can actually
|
||||
* satisfy — previously it gated on `poaName`, for which no input existed
|
||||
* anywhere, leaving a foreign freight forwarder permanently stuck.
|
||||
*/
|
||||
const poaTyped = Boolean(
|
||||
watch("poaName")?.trim() &&
|
||||
watch("poaEmail")?.trim() &&
|
||||
watch("poaPhone")?.trim(),
|
||||
);
|
||||
const poaEstablished =
|
||||
(identity?.poa.verified ?? false) ||
|
||||
(identity ? !identity.faydaRequired && poaTyped : false);
|
||||
|
||||
/**
|
||||
* Drop an optional representative the company no longer wants.
|
||||
*
|
||||
* Verifying a PoA is one click on a step that calls itself optional, and it
|
||||
* is not reversible from the form: the verification owns the fields (so
|
||||
* blanking them is refused), and its mere existence makes the DARS paper due
|
||||
* — which then blocks the submit AND clamps the resume back to this step. The
|
||||
* settings page has the same escape hatch, but `/settings` is off-limits
|
||||
* until onboarding finishes, so without this the customer is stuck.
|
||||
*
|
||||
* Not offered to a freight forwarder: the API refuses (they must have one).
|
||||
*/
|
||||
const [poaRemovePending, setPoaRemovePending] = useState(false);
|
||||
const removePoa = async () => {
|
||||
setSaveError(null);
|
||||
setPoaRemovePending(true);
|
||||
try {
|
||||
await verifaydaService.removePoa();
|
||||
for (const key of [
|
||||
"poaName",
|
||||
"poaEmail",
|
||||
"poaPhone",
|
||||
"poaAddress",
|
||||
"poaLocation",
|
||||
] as const) {
|
||||
setValue(key, "", { shouldDirty: false });
|
||||
}
|
||||
// The paper is deleted server-side with the identity; a copy still
|
||||
// sitting in the picker would be re-uploaded on the documents step.
|
||||
setDocumentFiles({ ...documentFiles, [POA_DELEGATION_FILE_KEY]: null });
|
||||
onIdentityChange?.();
|
||||
} catch (err) {
|
||||
setSaveError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error
|
||||
? err.message
|
||||
: "Could not remove the Power of Attorney"),
|
||||
: "Could not save your answer"),
|
||||
);
|
||||
} finally {
|
||||
setPoaRemovePending(false);
|
||||
setDeclarePending(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* What no source supplied, per person.
|
||||
*
|
||||
* A Fayda verification owns the fields its claims filled — the API refuses to
|
||||
* let those be overwritten — but its email, phone and address claims are
|
||||
* optional and routinely come back empty. eTrade fills the owner's name and
|
||||
* phone, and nothing at all fills an email.
|
||||
*
|
||||
* So "what still has to be asked" varies per company. Computed here, once,
|
||||
* and handed to both the step (which renders an input per gap) and the schema
|
||||
* (which requires exactly those): **a field is required if and only if there
|
||||
* is an input on screen to fix it in.**
|
||||
*/
|
||||
const ownerGaps = {
|
||||
name: !identity?.owner.name?.trim(),
|
||||
email: !identity?.owner.email?.trim(),
|
||||
phone: !identity?.owner.phone?.trim(),
|
||||
};
|
||||
const poaGaps = {
|
||||
name: !identity?.poa.name?.trim(),
|
||||
email: !identity?.poa.email?.trim(),
|
||||
phone: !identity?.poa.phone?.trim(),
|
||||
address: !identity?.poa.address?.trim(),
|
||||
};
|
||||
|
||||
// The owner's name from whichever source established them — powers the
|
||||
// contact step's "same as owner" card.
|
||||
const ownerName = firstPresent(identity?.owner.name, watch("ownerName"));
|
||||
const ownerEmail = firstPresent(identity?.owner.email, watch("ownerEmail"));
|
||||
const ownerPhone = firstPresent(identity?.owner.phone, watch("ownerPhone"));
|
||||
|
||||
const [contactSameAsOwner, setContactSameAsOwner] = useState(false);
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!contactSameAsGm) return;
|
||||
setValue("contactPersonName", gmName ?? "", { shouldValidate: true });
|
||||
setValue("contactPersonEmail", gmEmail ?? "");
|
||||
setValue("contactPersonPhone", gmPhone ?? "", { shouldValidate: true });
|
||||
if (!contactSameAsOwner) return;
|
||||
setValue("contactPersonName", ownerName, { shouldValidate: true });
|
||||
setValue("contactPersonEmail", ownerEmail);
|
||||
setValue("contactPersonPhone", ownerPhone, { shouldValidate: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
}, [contactSameAsOwner, ownerName, ownerEmail, ownerPhone]);
|
||||
|
||||
const toggleContactSameAsGm = (checked: boolean) => {
|
||||
setContactSameAsGm(checked);
|
||||
const toggleContactSameAsOwner = (checked: boolean) => {
|
||||
setContactSameAsOwner(checked);
|
||||
// Checked → the mirror effect fills the fields; unchecked → reset them.
|
||||
if (!checked) {
|
||||
setValue("contactPersonName", "");
|
||||
@@ -616,10 +440,10 @@ export default function CompanyProfileForm({
|
||||
};
|
||||
|
||||
// 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
|
||||
// the details it evidences — so it's split out here and the Documents step
|
||||
// renders the remainder. Both halves share `documentFiles`, so the existing
|
||||
// bulk upload still carries it.
|
||||
// rest (the API guarantees it is there), but belongs on the representation
|
||||
// step next to the details it evidences — so it's split out here and the
|
||||
// Documents step renders the remainder. Both halves share `documentFiles`, so
|
||||
// the existing bulk upload still carries it.
|
||||
const poaDocumentField = uploadSetting?.fields?.find(
|
||||
(f) => f.fileKey === POA_DELEGATION_FILE_KEY,
|
||||
);
|
||||
@@ -713,8 +537,7 @@ export default function CompanyProfileForm({
|
||||
|
||||
// The registration/license details come straight from the eTrade lookup and
|
||||
// are not user-editable — shown as a read-only confirmation once a TIN lookup
|
||||
// (or rehydration) has filled them in. The address fields below are separate:
|
||||
// user-entered and required. We watch the values so the display stays current.
|
||||
// (or rehydration) has filled them in.
|
||||
const registration = watch([
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
@@ -732,51 +555,17 @@ export default function CompanyProfileForm({
|
||||
// progress bar all derive from this so adding/removing a step is one edit.
|
||||
const stepOrder: CompanyStep[] = [
|
||||
"company",
|
||||
"personnel",
|
||||
"owner",
|
||||
"representation",
|
||||
"contact",
|
||||
"poa",
|
||||
"documents",
|
||||
];
|
||||
const currentIdx = stepOrder.indexOf(step);
|
||||
|
||||
// The DARS delegation paper is what proves the representative was actually
|
||||
// delegated, so it's required the moment a PoA exists. The API enforces the
|
||||
// same rule on save, so skipping it here only costs the customer a
|
||||
// round-trip.
|
||||
//
|
||||
// "Exists" is the API's own test (`POA_ATTRIBUTES.some(...)`): ANY detail,
|
||||
// verified or typed. Requiring a complete typed representative here instead
|
||||
// hid the upload from a customer who had entered only a name — for whom the
|
||||
// API still demands the paper, and whose resume would then be clamped back to
|
||||
// this step with nothing on it to fill.
|
||||
const poaAnyDetail = [
|
||||
identity?.poa.name,
|
||||
identity?.poa.email,
|
||||
identity?.poa.phone,
|
||||
identity?.poa.address,
|
||||
watch("poaName"),
|
||||
watch("poaEmail"),
|
||||
watch("poaPhone"),
|
||||
watch("poaLocation"),
|
||||
].some((v) => v?.trim());
|
||||
const poaProvided = (identity?.poa.verified ?? false) || poaAnyDetail;
|
||||
// A freight forwarder owes the paper whether or not its representative could
|
||||
// verify with Fayda — the API demands it at completion either way. Keying
|
||||
// this on the verification alone hid the upload from a foreign forwarder and
|
||||
// then failed them on submit for a file they were never shown.
|
||||
//
|
||||
// Unless the owner represents the company themselves: nobody delegates to
|
||||
// themselves, so there is no delegation to evidence. Mirrors the API's own
|
||||
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
|
||||
// demands a file the server would accept the submission without.
|
||||
//
|
||||
// Split from `poaDue` — "there is a representative, so their details are
|
||||
// owed" — because a self-PoA keeps the second while dropping the first. The
|
||||
// API draws the same line (`poaDue` / `delegationDue` in
|
||||
// getOnboardingRequirements); anything that is about the *details* must key
|
||||
// on `poaDue`, only the paper keys on this.
|
||||
const poaDue = poaProvided || requirePoa;
|
||||
const delegationRequired = poaDue && !poaSameAsOwner;
|
||||
// The DARS delegation paper is owed exactly when the company says it has a
|
||||
// representative. The API enforces the same rule on save, so skipping it here
|
||||
// only costs the customer a round-trip.
|
||||
const delegationRequired = identity?.poaDeclared === "yes";
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -785,90 +574,30 @@ export default function CompanyProfileForm({
|
||||
})();
|
||||
|
||||
/**
|
||||
* What a Fayda verification did NOT supply, per person.
|
||||
* Is the company's one identity proven?
|
||||
*
|
||||
* Fayda's email, phone and address claims are optional and routinely come
|
||||
* back empty, so a *verified* person can still be missing details the API
|
||||
* demands (`REQUIRED_POA_FIELDS`, `REQUIRED_COMPANY_INFO`). Those gaps are
|
||||
* typed instead — the API keeps exactly the keys a claim left empty typeable,
|
||||
* since a claim that returned nothing owns no value to protect.
|
||||
*
|
||||
* Computed here, once, and handed to both the step (which renders an input
|
||||
* per gap) and the schema (which requires exactly those) — a field is
|
||||
* required if and only if there is an input on screen to fix it in.
|
||||
* `identity.identityProven` is the server's verdict, but it is a step behind
|
||||
* a passport number the customer has just typed and not yet saved — so the
|
||||
* live form value counts too. Blocking on the stale server value would refuse
|
||||
* to advance past a field the customer has visibly filled in.
|
||||
*/
|
||||
// Where Fayda is mandatory an unverified representative must verify rather
|
||||
// than be typed, so nothing is offered until the verification lands.
|
||||
const poaTypedAllowed =
|
||||
!identity || identity.poa.verified || !identity.faydaRequired;
|
||||
// A representative the verification never proved holds only typed details —
|
||||
// the API leaves those unlocked, so the inputs stay on screen and stay
|
||||
// editable. Only a *verified* PoA hides the fields their claim did fill,
|
||||
// which is also the only case the API refuses to let anyone overwrite.
|
||||
const poaGap = (v?: string | null) =>
|
||||
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
|
||||
/**
|
||||
* "Same as owner" answers each field only as far as the owner actually has
|
||||
* one. Fayda's name, email and phone claims are all optional, the account and
|
||||
* eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
|
||||
* demands a name, an email and a phone — so anything the copy could not
|
||||
* supply stays askable. Assuming the copy filled everything is what dead-ends
|
||||
* the submit on "Add your poa phone" with no input anywhere to satisfy it.
|
||||
*
|
||||
* Keyed on the *source*, never on the field's current value: an input that
|
||||
* disappears the moment the first character is typed into it is unusable.
|
||||
* A verified owner's identity is copied server-side, so `identity.poa` is the
|
||||
* source there; otherwise it is the same owner-derived values the mirror
|
||||
* effect writes.
|
||||
*/
|
||||
const poaCopyGap = (copied?: string | null, mirrored?: string | null) =>
|
||||
identity?.owner.verified ? !copied?.trim() : !mirrored?.trim();
|
||||
const poaGaps = poaSameAsOwner
|
||||
? {
|
||||
name: poaCopyGap(identity?.poa.name, ownerSourceName),
|
||||
email: poaCopyGap(identity?.poa.email, ownerSourceEmail),
|
||||
phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone),
|
||||
// The location is the one detail the API never demands, so a blank one
|
||||
// dead-ends nothing — and asking for the owner's city under a card that
|
||||
// says "same as owner" reads as a contradiction.
|
||||
address: false,
|
||||
}
|
||||
: {
|
||||
name: poaGap(identity?.poa.name),
|
||||
email: poaGap(identity?.poa.email),
|
||||
phone: poaGap(identity?.poa.phone),
|
||||
address: poaGap(identity?.poa.address),
|
||||
};
|
||||
// The GM's own verification never falls back to the signed-in account — that
|
||||
// account is the person onboarding, not necessarily the manager — so a GM
|
||||
// verified with no email claim has nowhere else for one to come from. The
|
||||
// name claim is optional too, and `REQUIRED_COMPANY_INFO` demands it, so it
|
||||
// gets the same treatment rather than dead-ending the submit.
|
||||
// "Same as owner" is exempt: the API copies the owner's (account-backed)
|
||||
// contact details across, so there is no gap and no input.
|
||||
const gmGaps = {
|
||||
name: !gmSameAsOwner && gmVerified && !identity?.gm.name?.trim(),
|
||||
email: !gmSameAsOwner && gmVerified && !identity?.gm.email?.trim(),
|
||||
phone: !gmSameAsOwner && gmVerified && !identity?.gm.phone?.trim(),
|
||||
};
|
||||
const passportField =
|
||||
identity?.subject === "poa" ? "poaPassportNumber" : "ownerPassportNumber";
|
||||
const identityProven =
|
||||
(identity?.identityProven ?? false) ||
|
||||
((identity?.passportAccepted ?? false) &&
|
||||
Boolean(watch(passportField)?.trim()));
|
||||
|
||||
const requiredKeys: (keyof FormData)[] = [];
|
||||
if (step === "personnel") {
|
||||
// The manager's email is offered but not demanded: the API dropped it from
|
||||
// `REQUIRED_COMPANY_INFO` once the notify resolver stopped depending on it.
|
||||
// The name and phone are still required there, so they are still required
|
||||
// here.
|
||||
if (gmGaps.name) requiredKeys.push("generalManagerName");
|
||||
if (gmGaps.phone) requiredKeys.push("generalManagerPhone");
|
||||
} else if (step === "poa" && poaDue) {
|
||||
// Only once a PoA is required or provided: an untouched optional PoA is
|
||||
// still a step the customer may walk straight past.
|
||||
//
|
||||
// `poaDue`, NOT `delegationRequired`: the paper is waived for a self-PoA
|
||||
// but `REQUIRED_POA_FIELDS` is not, and the API reports every one of them
|
||||
// missing (`missingPoaFields` keys on its own `poaDue`) — which fails the
|
||||
// submit and clamps the resume back here. Keying this on the paper let the
|
||||
// customer walk past an input this step had already put on screen.
|
||||
if (step === "owner") {
|
||||
// All three are required by the API (`REQUIRED_COMPANY_INFO`), and an input
|
||||
// is rendered for each one a Fayda claim did not already own.
|
||||
if (ownerGaps.name) requiredKeys.push("ownerName");
|
||||
if (ownerGaps.email) requiredKeys.push("ownerEmail");
|
||||
if (ownerGaps.phone) requiredKeys.push("ownerPhone");
|
||||
} else if (step === "representation" && identity?.poaDeclared === "yes") {
|
||||
// Only once a representative is actually declared: a company that answered
|
||||
// "no" has no representative to describe.
|
||||
if (poaGaps.name) requiredKeys.push("poaName");
|
||||
if (poaGaps.email) requiredKeys.push("poaEmail");
|
||||
if (poaGaps.phone) requiredKeys.push("poaPhone");
|
||||
@@ -879,19 +608,15 @@ export default function CompanyProfileForm({
|
||||
* Collect the messages for a set of fields into one sentence.
|
||||
*
|
||||
* A failed `trigger()` used to return silently, so Continue simply did
|
||||
* nothing — and every field whose input is conditionally rendered (or derived
|
||||
* and never rendered at all) turned into an invisible dead end. Naming the
|
||||
* failures is the whole point: the ones worth reporting are exactly the ones
|
||||
* with no error text on screen to read.
|
||||
* nothing — and every field whose input is conditionally rendered turned into
|
||||
* an invisible dead end. Naming the failures is the whole point: the ones
|
||||
* worth reporting are exactly the ones with no error text on screen to read.
|
||||
*/
|
||||
const describeErrors = (fields: (keyof FormData)[]): string => {
|
||||
// Re-parse rather than read `errors`: that's the render-time snapshot, and
|
||||
// this runs immediately after an `await trigger()` that has not re-rendered
|
||||
// yet, so the closure would still be holding the previous attempt's state.
|
||||
const parsed = buildOnboardingSchema(
|
||||
identity?.passportRequired === true,
|
||||
requiredKeys,
|
||||
).safeParse(getValues());
|
||||
const parsed = buildOnboardingSchema(requiredKeys).safeParse(getValues());
|
||||
const wanted = new Set<string>(fields as string[]);
|
||||
const messages = parsed.success
|
||||
? []
|
||||
@@ -903,21 +628,10 @@ export default function CompanyProfileForm({
|
||||
: "Some details on this step are incomplete. Please review the fields above.";
|
||||
};
|
||||
|
||||
/**
|
||||
* The fields this step actually validates. `stepFields` covers what the step
|
||||
* always renders; the company step additionally exposes company email/phone
|
||||
* as inputs when nothing could be derived for them, and a field is validated
|
||||
* exactly when the customer can see and fix it.
|
||||
*/
|
||||
const fieldsForStep = (s: CompanyStep): (keyof FormData)[] => {
|
||||
if (s !== "company" || !identity) return stepFields[s];
|
||||
return [...stepFields.company];
|
||||
};
|
||||
|
||||
/** Validate + persist the current step, returning whether we may advance. */
|
||||
const saveCurrentStep = async (): Promise<boolean> => {
|
||||
setSaveError(null);
|
||||
const fields = fieldsForStep(step);
|
||||
const fields = stepFields[step];
|
||||
const isValid = await trigger(fields);
|
||||
if (!isValid) {
|
||||
setSaveError(describeErrors(fields));
|
||||
@@ -969,13 +683,13 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
|
||||
setSaveError(null);
|
||||
// Deliberately NOT `handleSubmit`: that re-validated all 34 schema fields
|
||||
// — including every field belonging to a step that isn't on screen — and
|
||||
// on failure did nothing at all, no alert and no navigation, which is the
|
||||
// Deliberately NOT `handleSubmit`: that re-validated every schema field —
|
||||
// including every field belonging to a step that isn't on screen — and on
|
||||
// failure did nothing at all, no alert and no navigation, which is the
|
||||
// "Submit for review" button that appears dead. Each step has already
|
||||
// validated and saved its own fields, and the API's `markOnboardingComplete`
|
||||
// is the authority on what is still outstanding; its message reaches the
|
||||
// customer through `submitError`.
|
||||
// validated and saved its own fields, and the API's
|
||||
// `markOnboardingComplete` is the authority on what is still outstanding;
|
||||
// its message reaches the customer through `submitError`.
|
||||
onSubmit(buildPayload(getValues(), user));
|
||||
return;
|
||||
}
|
||||
@@ -995,66 +709,50 @@ export default function CompanyProfileForm({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Fayda verification is proved outside the form state, so it gates here
|
||||
// rather than through zod. The passport number is a plain typed field —
|
||||
// buildOnboardingSchema already requires it when passportRequired, so
|
||||
// saveCurrentStep()'s trigger() below catches that; checking the stale
|
||||
// server-side identity.owner.passportNumber here would block a value the
|
||||
// user just typed but hasn't saved yet.
|
||||
if (
|
||||
step === "company" &&
|
||||
identity?.faydaRequired &&
|
||||
!identity.owner.verified
|
||||
) {
|
||||
// The declaration decides whose identity is verified, so it has to be
|
||||
// answered before the verification below can mean anything.
|
||||
if (step === "representation" && !identity?.poaDeclared) {
|
||||
setSaveError(
|
||||
"Verify the company owner's identity with Fayda before continuing.",
|
||||
"Tell us whether anyone holds power of attorney for this company.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The GM is established through Fayda now, so the step gates on the
|
||||
// identity rather than on typed text — same strength as the old required
|
||||
// fields, different evidence. A foreign company's GM may hold no Fayda ID,
|
||||
// so typed details still satisfy it there.
|
||||
if (step === "personnel" && !gmEstablished) {
|
||||
// The verification itself is proved outside the form state, so it gates
|
||||
// here rather than through zod.
|
||||
if (step === "representation" && !identityProven) {
|
||||
const who =
|
||||
identity?.subject === "poa"
|
||||
? "your Power of Attorney"
|
||||
: "the company owner";
|
||||
setSaveError(
|
||||
identity?.faydaRequired
|
||||
? "Verify the general manager with Fayda, or tick “same as owner” if they are the company's owner."
|
||||
: "Add the general manager's details, or verify them with Fayda.",
|
||||
identity?.passportAccepted
|
||||
? `Verify ${who} with Fayda, or enter their passport number.`
|
||||
: `Verify ${who} with Fayda before continuing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (step === "poa" && requirePoa && !poaEstablished) {
|
||||
setSaveError(
|
||||
identity?.faydaRequired
|
||||
? "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 a Power of Attorney is required.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The PoA step also gates on a file, which lives outside the form state.
|
||||
if (step === "poa" && delegationRequired && !delegationPresent) {
|
||||
// The step also gates on a file, which lives outside the form state.
|
||||
if (step === "representation" && delegationRequired && !delegationPresent) {
|
||||
setDocumentFieldErrors({
|
||||
[POA_DELEGATION_FILE_KEY]: "DARS delegation paper is required",
|
||||
});
|
||||
// Validate the text fields too, so every problem shows at once.
|
||||
const fieldsOk = await trigger(stepFields.poa);
|
||||
const fieldsOk = await trigger(stepFields.representation);
|
||||
setSaveError(
|
||||
[
|
||||
requirePoa
|
||||
? "Freight forwarders must provide Power of Attorney details and the DARS delegation paper."
|
||||
: "Upload the DARS delegation paper for the Power of Attorney you entered, or clear the PoA details to skip.",
|
||||
fieldsOk ? null : describeErrors(stepFields.poa),
|
||||
"Upload the DARS delegation paper for the representative you named.",
|
||||
fieldsOk ? null : describeErrors(stepFields.representation),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The API will not accept the PoA's details until the paper evidencing the
|
||||
// delegation is actually on file, so the selection made on this step has to
|
||||
// be uploaded before the save — not held back until the documents step,
|
||||
// which is unreachable while this save keeps failing.
|
||||
if (step === "poa" && delegationRequired && onUploadDocuments) {
|
||||
// The API will not accept the representative's details until the paper
|
||||
// evidencing the delegation is actually on file, so the selection made on
|
||||
// this step has to be uploaded before the save — not held back until the
|
||||
// documents step, which is unreachable while this save keeps failing.
|
||||
if (step === "representation" && delegationRequired && onUploadDocuments) {
|
||||
const pending = documentFiles[POA_DELEGATION_FILE_KEY];
|
||||
const hasPending = Array.isArray(pending)
|
||||
? pending.length > 0
|
||||
@@ -1092,8 +790,6 @@ export default function CompanyProfileForm({
|
||||
{step === "company" && (
|
||||
<CompanyInfoStep
|
||||
form={form}
|
||||
identity={identity}
|
||||
verifiedIdentity={verifiedIdentity}
|
||||
tinStatus={tinStatus}
|
||||
tinVerified={tinVerified}
|
||||
hasRegistrationDetails={hasRegistrationDetails}
|
||||
@@ -1103,46 +799,36 @@ export default function CompanyProfileForm({
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "personnel" && (
|
||||
<PersonnelStep
|
||||
{step === "owner" && (
|
||||
<OwnerStep
|
||||
form={form}
|
||||
identity={identity}
|
||||
etradeOwner={etradeOwner}
|
||||
gmSameAsOwner={gmSameAsOwner}
|
||||
onToggleGmSameAsOwner={toggleGmSameAsOwner}
|
||||
gmLinkPending={gmLinkPending}
|
||||
gmVerified={gmVerified}
|
||||
gaps={gmGaps}
|
||||
gaps={ownerGaps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "representation" && (
|
||||
<RepresentationStep
|
||||
form={form}
|
||||
identity={identity}
|
||||
onDeclare={handleDeclare}
|
||||
declarePending={declarePending}
|
||||
gaps={poaGaps}
|
||||
poaDocumentSetting={poaDocumentSetting}
|
||||
documentFiles={documentFiles}
|
||||
uploadedDocumentKeys={uploadedDocumentKeys}
|
||||
documentFieldErrors={documentFieldErrors}
|
||||
onDocumentFilesChange={handleDocumentFilesChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "contact" && (
|
||||
<ContactStep
|
||||
form={form}
|
||||
gmName={gmName}
|
||||
contactSameAsGm={contactSameAsGm}
|
||||
onToggleContactSameAsGm={toggleContactSameAsGm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "poa" && (
|
||||
<PoaStep
|
||||
form={form}
|
||||
identity={identity}
|
||||
requirePoa={requirePoa}
|
||||
poaSameAsOwner={poaSameAsOwner}
|
||||
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
|
||||
poaLinkPending={poaLinkPending}
|
||||
etradeOwner={etradeOwner}
|
||||
gaps={poaGaps}
|
||||
onRemovePoa={removePoa}
|
||||
removePending={poaRemovePending}
|
||||
delegationRequired={delegationRequired}
|
||||
poaDocumentSetting={poaDocumentSetting}
|
||||
documentFiles={documentFiles}
|
||||
uploadedDocumentKeys={uploadedDocumentKeys}
|
||||
documentFieldErrors={documentFieldErrors}
|
||||
onDocumentFilesChange={handleDocumentFilesChange}
|
||||
ownerName={ownerName}
|
||||
contactSameAsOwner={contactSameAsOwner}
|
||||
onToggleContactSameAsOwner={toggleContactSameAsOwner}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1197,6 +883,7 @@ export default function CompanyProfileForm({
|
||||
disabled={
|
||||
isPending ||
|
||||
saving ||
|
||||
declarePending ||
|
||||
(step === "documents" && !hasDocuments && loadingDocuments)
|
||||
}
|
||||
loading={isPending || saving}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Where a prefilled value came from, shown as a badge next to it. */
|
||||
export type FieldSource = "eTrade" | "Fayda";
|
||||
|
||||
const SOURCE_NOTE: Record<FieldSource, string> = {
|
||||
eTrade: "From your eTrade licence",
|
||||
Fayda: "From the Fayda verification",
|
||||
};
|
||||
|
||||
/**
|
||||
* One person-detail field that may already be answered for us.
|
||||
*
|
||||
* The onboarding wizard fills what it can from the eTrade lookup and the Fayda
|
||||
* verification, and asks the customer only for what neither supplied. Both
|
||||
* sources are patchy in practice — eTrade returns no email at all and often no
|
||||
* manager name; Fayda's email and phone claims are optional and routinely come
|
||||
* back empty — so "what is missing" varies per company and cannot be decided
|
||||
* once at build time.
|
||||
*
|
||||
* This is the single place that decision is rendered: a supplied value shows
|
||||
* read-only with its provenance, a gap shows the input. It pairs with
|
||||
* `requiredKeys` in CompanyProfileForm, which requires exactly the fields that
|
||||
* fall through to `children` — the invariant being that **a field is required
|
||||
* if and only if there is an input on screen to satisfy it**.
|
||||
*/
|
||||
export default function SourcedField({
|
||||
label,
|
||||
value,
|
||||
source,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
/** The value a source supplied. Blank/absent means "ask the customer". */
|
||||
value?: string | null;
|
||||
source: FieldSource;
|
||||
/** The input rendered when no source supplied a value. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!value?.trim()) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="edr-green">
|
||||
{source}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text className="wrap-break-word" size="sm" c="edr-text" fw={500}>
|
||||
{value}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{SOURCE_NOTE[source]}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -69,7 +69,6 @@ export function normalizeIdentityPhones(
|
||||
...identity,
|
||||
owner: fix(identity.owner),
|
||||
poa: fix(identity.poa),
|
||||
gm: fix(identity.gm),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,15 +96,14 @@ export function buildPayload(
|
||||
vatNumber: data.vatNumber,
|
||||
attributes: {
|
||||
ownerPassportNumber: data.ownerPassportNumber || undefined,
|
||||
poaPassportNumber: data.poaPassportNumber || undefined,
|
||||
contactPersonName: data.contactPersonName,
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
generalManagerName: data.generalManagerName,
|
||||
generalManagerEmail: data.generalManagerEmail,
|
||||
generalManagerPhone: data.generalManagerPhone,
|
||||
// The representative's own details are written by their Fayda
|
||||
// verification, so the city is all the form has to send.
|
||||
ownerName: data.ownerName,
|
||||
ownerEmail: data.ownerEmail,
|
||||
ownerPhone: data.ownerPhone,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
},
|
||||
};
|
||||
@@ -137,21 +135,20 @@ export function stepPayload(
|
||||
return {
|
||||
companyAddress: d.companyAddress,
|
||||
vatNumber: d.vatNumber,
|
||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||
...etrade,
|
||||
};
|
||||
}
|
||||
case "personnel":
|
||||
case "owner":
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null and
|
||||
// undefined, so an empty string is validated and 400s with
|
||||
// "generalManagerEmail must be an email". An Ethiopian company never types
|
||||
// these — the GM comes from the Fayda verification (or the "same as owner"
|
||||
// declaration), so the form fields are legitimately blank and would fail a
|
||||
// step that has no input to fix.
|
||||
// "ownerEmail must be an email". A field the eTrade lookup or the Fayda
|
||||
// claim already filled is legitimately blank in the form — it has no
|
||||
// input — so sending "" would fail a step with nothing on screen to fix.
|
||||
return {
|
||||
generalManagerName: d.generalManagerName || undefined,
|
||||
generalManagerEmail: d.generalManagerEmail || undefined,
|
||||
generalManagerPhone: d.generalManagerPhone || undefined,
|
||||
ownerName: d.ownerName || undefined,
|
||||
ownerEmail: d.ownerEmail || undefined,
|
||||
ownerPhone: d.ownerPhone || undefined,
|
||||
ownerPassportNumber: d.ownerPassportNumber || undefined,
|
||||
};
|
||||
case "contact":
|
||||
return {
|
||||
@@ -160,12 +157,13 @@ export function stepPayload(
|
||||
contactPersonEmail: d.contactPersonEmail || undefined,
|
||||
contactPersonPhone: d.contactPersonPhone,
|
||||
};
|
||||
case "poa":
|
||||
case "representation":
|
||||
return {
|
||||
poaName: d.poaName || undefined,
|
||||
poaEmail: d.poaEmail || undefined,
|
||||
poaPhone: d.poaPhone || undefined,
|
||||
poaLocation: d.poaLocation || undefined,
|
||||
poaPassportNumber: d.poaPassportNumber || undefined,
|
||||
};
|
||||
default:
|
||||
return {};
|
||||
@@ -183,6 +181,7 @@ export function toFormValues(p: ProfileResponse): FormData {
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
ownerPassportNumber: p.identity?.owner.passportNumber ?? "",
|
||||
poaPassportNumber: p.identity?.poa.passportNumber ?? "",
|
||||
licenceNumber: p.licenceNumber ?? "",
|
||||
statusDescription: p.statusDescription ?? "",
|
||||
dateRegistered: p.dateRegistered ?? "",
|
||||
@@ -198,9 +197,9 @@ export function toFormValues(p: ProfileResponse): FormData {
|
||||
contactPersonPosition: p.contactPersonPosition ?? "",
|
||||
contactPersonEmail: p.contactPersonEmail ?? "",
|
||||
contactPersonPhone: p.contactPersonPhone ?? "",
|
||||
generalManagerName: p.generalManagerName ?? "",
|
||||
generalManagerEmail: p.generalManagerEmail ?? "",
|
||||
generalManagerPhone: p.generalManagerPhone ?? "",
|
||||
ownerName: p.ownerName ?? "",
|
||||
ownerEmail: p.ownerEmail ?? "",
|
||||
ownerPhone: p.ownerPhone ?? "",
|
||||
poaName: p.poaName ?? "",
|
||||
poaPhone: p.poaPhone ?? "",
|
||||
poaAddress: p.poaAddress ?? "",
|
||||
|
||||
@@ -35,9 +35,9 @@ const values = (over: Partial<FormData> = {}): FormData =>
|
||||
contactPersonPosition: "",
|
||||
contactPersonEmail: "",
|
||||
contactPersonPhone: "+251911223344",
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
ownerName: "",
|
||||
ownerEmail: "",
|
||||
ownerPhone: "",
|
||||
poaName: "",
|
||||
poaPhone: "",
|
||||
poaAddress: "",
|
||||
@@ -131,31 +131,29 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
|
||||
data: FormData,
|
||||
required: (keyof FormData)[],
|
||||
): (keyof FormData)[] => {
|
||||
const parsed = buildOnboardingSchema(false, required).safeParse(data);
|
||||
const parsed = buildOnboardingSchema(required).safeParse(data);
|
||||
return parsed.success
|
||||
? []
|
||||
: (parsed.error.issues.map((i) => i.path[0]) as (keyof FormData)[]);
|
||||
};
|
||||
|
||||
// Fayda's email/phone claims are optional: the step renders an input for what
|
||||
// the verification did not supply, and requires exactly those. Nothing else —
|
||||
// a field with no input on screen must never fail Continue.
|
||||
// eTrade returns no email and Fayda's email/phone claims are optional: the
|
||||
// step renders an input for what no source supplied, and requires exactly
|
||||
// those. Nothing else — a field with no input on screen must never fail
|
||||
// Continue.
|
||||
it("requires only the keys it is handed", () => {
|
||||
const issues = issuesFor(values(), [
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
]);
|
||||
expect(issues).toEqual(["generalManagerEmail", "generalManagerPhone"]);
|
||||
const issues = issuesFor(values(), ["ownerEmail", "ownerPhone"]);
|
||||
expect(issues).toEqual(["ownerEmail", "ownerPhone"]);
|
||||
});
|
||||
|
||||
it("passes once those keys are filled", () => {
|
||||
expect(
|
||||
issuesFor(
|
||||
values({
|
||||
generalManagerEmail: "gm@example.com",
|
||||
generalManagerPhone: "+251911223344",
|
||||
ownerEmail: "owner@example.com",
|
||||
ownerPhone: "+251911223344",
|
||||
}),
|
||||
["generalManagerEmail", "generalManagerPhone"],
|
||||
["ownerEmail", "ownerPhone"],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
@@ -165,9 +163,7 @@ describe("buildOnboardingSchema (conditionally required fields)", () => {
|
||||
});
|
||||
|
||||
it("names the field in the message, so it reads under its own input", () => {
|
||||
const parsed = buildOnboardingSchema(false, ["poaEmail"]).safeParse(
|
||||
values(),
|
||||
);
|
||||
const parsed = buildOnboardingSchema(["poaEmail"]).safeParse(values());
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) return;
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
@@ -198,37 +194,33 @@ describe("stepPayload (company)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepPayload (personnel)", () => {
|
||||
// An Ethiopian company never types the GM — Fayda (or "same as owner") owns
|
||||
// those fields — so the form holds "". `@IsOptional()` on the DTO skips only
|
||||
// null/undefined, so an empty string is validated and comes back as
|
||||
// "generalManagerEmail must be an email", on a step that renders no input.
|
||||
it("omits blank GM fields instead of sending empty strings", () => {
|
||||
describe("stepPayload (owner)", () => {
|
||||
// A Fayda claim owns whatever it supplied, so the form holds "" for those.
|
||||
// `@IsOptional()` on the DTO skips only null/undefined, so an empty string is
|
||||
// validated and comes back as "ownerEmail must be an email" — on a step that
|
||||
// renders no input for it.
|
||||
it("omits blank owner fields instead of sending empty strings", () => {
|
||||
const payload = stepPayload(
|
||||
"personnel",
|
||||
values({
|
||||
generalManagerName: "",
|
||||
generalManagerEmail: "",
|
||||
generalManagerPhone: "",
|
||||
}),
|
||||
"owner",
|
||||
values({ ownerName: "", ownerEmail: "", ownerPhone: "" }),
|
||||
);
|
||||
expect(payload.generalManagerName).toBeUndefined();
|
||||
expect(payload.generalManagerEmail).toBeUndefined();
|
||||
expect(payload.generalManagerPhone).toBeUndefined();
|
||||
expect(payload.ownerName).toBeUndefined();
|
||||
expect(payload.ownerEmail).toBeUndefined();
|
||||
expect(payload.ownerPhone).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still sends typed GM details (foreign company)", () => {
|
||||
it("sends the owner details the customer typed or eTrade prefilled", () => {
|
||||
const payload = stepPayload(
|
||||
"personnel",
|
||||
"owner",
|
||||
values({
|
||||
generalManagerName: "Abebe Bikila",
|
||||
generalManagerEmail: "gm@example.com",
|
||||
generalManagerPhone: "+251911223344",
|
||||
ownerName: "Abebe Bikila",
|
||||
ownerEmail: "owner@example.com",
|
||||
ownerPhone: "+251911223344",
|
||||
}),
|
||||
);
|
||||
expect(payload.generalManagerName).toBe("Abebe Bikila");
|
||||
expect(payload.generalManagerEmail).toBe("gm@example.com");
|
||||
expect(payload.generalManagerPhone).toBe("+251911223344");
|
||||
expect(payload.ownerName).toBe("Abebe Bikila");
|
||||
expect(payload.ownerEmail).toBe("owner@example.com");
|
||||
expect(payload.ownerPhone).toBe("+251911223344");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,10 +263,11 @@ describe("firstValidEmail", () => {
|
||||
describe("normalizeIdentityPhones", () => {
|
||||
it("converts a local Fayda phone claim to E.164", () => {
|
||||
const identity = {
|
||||
faydaRequired: true,
|
||||
passportRequired: false,
|
||||
passportAccepted: false,
|
||||
poaDeclared: "yes",
|
||||
subject: "poa",
|
||||
owner: {
|
||||
verified: true,
|
||||
verified: false,
|
||||
name: "A",
|
||||
phone: "0911223344",
|
||||
email: null,
|
||||
@@ -283,28 +276,22 @@ describe("normalizeIdentityPhones", () => {
|
||||
passportNumber: null,
|
||||
},
|
||||
poa: {
|
||||
verified: false,
|
||||
name: null,
|
||||
phone: null,
|
||||
email: null,
|
||||
address: null,
|
||||
verifiedAt: null,
|
||||
},
|
||||
gm: {
|
||||
verified: false,
|
||||
name: null,
|
||||
verified: true,
|
||||
name: "B",
|
||||
phone: "251911223344",
|
||||
email: null,
|
||||
address: null,
|
||||
verifiedAt: null,
|
||||
passportNumber: null,
|
||||
},
|
||||
gmSameAsOwner: false,
|
||||
complete: false,
|
||||
identityProven: true,
|
||||
etradeManagerName: null,
|
||||
ownerMatchesEtrade: null,
|
||||
complete: true,
|
||||
} as CompanyIdentityState;
|
||||
|
||||
const fixed = normalizeIdentityPhones(identity)!;
|
||||
expect(fixed.owner.phone).toBe("+251911223344");
|
||||
expect(fixed.gm.phone).toBe("+251911223344");
|
||||
expect(fixed.poa.phone).toBeNull();
|
||||
expect(fixed.poa.phone).toBe("+251911223344");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,9 @@ import { isValidPhone } from "@/components/PhoneField";
|
||||
|
||||
export type CompanyStep =
|
||||
| "company"
|
||||
| "personnel"
|
||||
| "owner"
|
||||
| "representation"
|
||||
| "contact"
|
||||
| "poa"
|
||||
| "documents"
|
||||
| "additional";
|
||||
|
||||
@@ -27,10 +27,13 @@ export const onboardingSchema = z.object({
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
|
||||
// The owner's passport number — the foreign-company identity credential
|
||||
// (Fayda is an Ethiopian national ID). Required only for a foreign company;
|
||||
// enforced in buildOnboardingSchema since that depends on `nationality`.
|
||||
// Passport numbers — the alternative identity credential for a foreign
|
||||
// company (Fayda is an Ethiopian national ID). Only the one belonging to the
|
||||
// declared identity subject is ever asked for, and only when that person has
|
||||
// not verified with Fayda — so requiredness is decided per render and lives
|
||||
// in `requiredKeys`, not here.
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
poaPassportNumber: z.string().optional(),
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
@@ -57,21 +60,23 @@ export const onboardingSchema = z.object({
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
// Optional here, not unrequired: the GM is now established by Fayda — either
|
||||
// verified in their own right or declared the same person as the owner — so
|
||||
// for an Ethiopian company these fields are never typed and would fail a
|
||||
// blanket `min(1)`. Presence is gated per nationality in the step's own
|
||||
// check, where the identity state is available; zod only polices format for
|
||||
// the foreign companies that still type them.
|
||||
generalManagerName: z.string().optional(),
|
||||
generalManagerEmail: z
|
||||
// The owner — whoever the eTrade licence names as the business's manager.
|
||||
//
|
||||
// Optional here, not unrequired: the eTrade lookup fills the name and phone,
|
||||
// and a Fayda verification can fill all three, so on a well-supplied company
|
||||
// none of them is typed and a blanket `min(1)` would fail a step with no
|
||||
// input on screen. What IS required is decided per render — a field is
|
||||
// required exactly when the step renders an input for it (`requiredKeys`).
|
||||
// zod only polices format here.
|
||||
ownerName: z.string().optional(),
|
||||
ownerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid Manager email",
|
||||
"Invalid owner email",
|
||||
),
|
||||
generalManagerPhone: z
|
||||
ownerPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
@@ -105,42 +110,36 @@ const CONDITIONAL_LABELS: Partial<Record<keyof FormData, string>> = {
|
||||
poaName: "Representative's name",
|
||||
poaEmail: "Representative's email",
|
||||
poaPhone: "Representative's phone",
|
||||
generalManagerName: "General manager's name",
|
||||
generalManagerEmail: "General manager's email",
|
||||
generalManagerPhone: "General manager's phone",
|
||||
poaPassportNumber: "Representative's passport number",
|
||||
ownerName: "Owner's name",
|
||||
ownerEmail: "Owner's email",
|
||||
ownerPhone: "Owner's phone",
|
||||
ownerPassportNumber: "Owner's passport number",
|
||||
};
|
||||
|
||||
/**
|
||||
* The PoA's and GM's identifying fields normally come from their Fayda
|
||||
* verification, so nothing in the base schema requires them. But Fayda's email
|
||||
* and phone claims are optional and routinely come back empty, and the steps
|
||||
* render an input for whatever the verification did not supply — so those
|
||||
* fields become mandatory exactly then.
|
||||
* The owner's and the representative's identifying fields arrive from three
|
||||
* places — the eTrade lookup, a Fayda verification, or the customer typing them
|
||||
* — and which one supplies what varies per company. eTrade returns no email at
|
||||
* all; Fayda's email and phone claims are optional and routinely come back
|
||||
* empty. So nothing in the base schema requires them, and the steps render an
|
||||
* input for whatever no source supplied.
|
||||
*
|
||||
* `requiredKeys` is that decision, made by CompanyProfileForm from the same
|
||||
* state that drives the rendering: a field is required iff an input exists for
|
||||
* it. Passing it in (rather than deriving it here) is what keeps the two from
|
||||
* drifting into a Continue button that fails on a field nobody can see.
|
||||
* state that drives the rendering: **a field is required iff an input exists
|
||||
* for it**. Passing it in (rather than deriving it here) is what keeps the two
|
||||
* from drifting into a Continue button that fails on a field nobody can see.
|
||||
*/
|
||||
export function buildOnboardingSchema(
|
||||
/** True for a foreign company: the owner's passport number is mandatory. */
|
||||
passportRequired = false,
|
||||
/** Fields the current step renders an input for and must not leave blank. */
|
||||
requiredKeys: readonly (keyof FormData)[] = [],
|
||||
) {
|
||||
if (!passportRequired && requiredKeys.length === 0) return onboardingSchema;
|
||||
if (requiredKeys.length === 0) return onboardingSchema;
|
||||
return onboardingSchema.superRefine((d, ctx) => {
|
||||
if (passportRequired && !d.ownerPassportNumber?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["ownerPassportNumber"],
|
||||
message: "The owner's passport number is required",
|
||||
});
|
||||
}
|
||||
for (const key of requiredKeys) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${CONDITIONAL_LABELS[key] ?? key} is required`,
|
||||
});
|
||||
@@ -184,25 +183,29 @@ export const ETRADE_BUNDLE_FIELDS = [
|
||||
* (`REQUIRED_COMPANY_INFO`), and reports it with a message.
|
||||
*/
|
||||
export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
// Only the three fields this step actually renders an input for. The company
|
||||
// name and the registered address are eTrade's, shown read-only.
|
||||
company: ["tinNumber", "vatNumber", "ownerPassportNumber"],
|
||||
personnel: [
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
],
|
||||
// Only what this step actually renders an input for. The company name and the
|
||||
// registered address are eTrade's, shown read-only.
|
||||
company: ["tinNumber", "vatNumber"],
|
||||
// `REQUIRED_COMPANY_INFO` demands all three server-side, so the step offers
|
||||
// an input wherever eTrade and Fayda between them left a gap.
|
||||
owner: ["ownerName", "ownerEmail", "ownerPhone", "ownerPassportNumber"],
|
||||
contact: [
|
||||
"contactPersonName",
|
||||
"contactPersonPosition",
|
||||
"contactPersonEmail",
|
||||
"contactPersonPhone",
|
||||
],
|
||||
// The API requires poaName/poaEmail/poaPhone from a freight forwarder
|
||||
// (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever the
|
||||
// representative isn't proven by Fayda — otherwise the save is rejected
|
||||
// naming fields the form never rendered.
|
||||
poa: ["poaName", "poaEmail", "poaPhone", "poaLocation"],
|
||||
// The API requires poaName/poaEmail/poaPhone once a representative is
|
||||
// declared (`REQUIRED_POA_FIELDS`), so the step has to offer them wherever
|
||||
// Fayda didn't supply them — otherwise the save is rejected naming fields the
|
||||
// form never rendered.
|
||||
representation: [
|
||||
"poaName",
|
||||
"poaEmail",
|
||||
"poaPhone",
|
||||
"poaLocation",
|
||||
"poaPassportNumber",
|
||||
],
|
||||
documents: [],
|
||||
additional: [],
|
||||
};
|
||||
|
||||
@@ -2,11 +2,9 @@ import { Stack, TextInput } from "@mantine/core";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
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";
|
||||
@@ -14,10 +12,6 @@ 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). */
|
||||
@@ -29,8 +23,6 @@ export interface CompanyInfoStepProps {
|
||||
|
||||
export default function CompanyInfoStep({
|
||||
form,
|
||||
identity,
|
||||
verifiedIdentity,
|
||||
tinStatus,
|
||||
tinVerified,
|
||||
hasRegistrationDetails,
|
||||
@@ -66,48 +58,6 @@ export default function CompanyInfoStep({
|
||||
|
||||
<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={
|
||||
|
||||
@@ -9,19 +9,19 @@ 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.
|
||||
* The owner's name from whichever source established them (eTrade, Fayda or
|
||||
* typed) — the "same as owner" card only makes sense once there is one.
|
||||
*/
|
||||
gmName?: string;
|
||||
contactSameAsGm: boolean;
|
||||
onToggleContactSameAsGm: (checked: boolean) => void;
|
||||
ownerName?: string;
|
||||
contactSameAsOwner: boolean;
|
||||
onToggleContactSameAsOwner: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ContactStep({
|
||||
form,
|
||||
gmName,
|
||||
contactSameAsGm,
|
||||
onToggleContactSameAsGm,
|
||||
ownerName,
|
||||
contactSameAsOwner,
|
||||
onToggleContactSameAsOwner,
|
||||
}: ContactStepProps) {
|
||||
const {
|
||||
register,
|
||||
@@ -34,15 +34,15 @@ export default function ContactStep({
|
||||
<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 && (
|
||||
{/* `ownerName`, not the raw form field: the owner's name usually comes
|
||||
from the eTrade lookup or a Fayda claim rather than being typed, so
|
||||
gating on the form value would hide this card from most companies. */}
|
||||
{ownerName && (
|
||||
<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."
|
||||
checked={contactSameAsOwner}
|
||||
onToggle={onToggleContactSameAsOwner}
|
||||
title="Same as company owner"
|
||||
description="Reuse the owner's name, email and phone. Uncheck to enter different details."
|
||||
/>
|
||||
)}
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Alert, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { ControlledPhoneField } from "@/components/PhoneField";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
|
||||
import type { FormData } from "../schema";
|
||||
import SourcedField from "../SourcedField";
|
||||
|
||||
export interface OwnerStepProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
identity?: CompanyIdentityState;
|
||||
/** eTrade's registered manager, once a TIN lookup has succeeded. */
|
||||
etradeOwner: { name: string; phone: string } | null;
|
||||
/**
|
||||
* Which of the owner's details neither eTrade nor Fayda supplied, and are
|
||||
* therefore typed here. Computed by CompanyProfileForm, which requires
|
||||
* exactly these in the schema — so every input below is one the customer is
|
||||
* actually asked to fill, and nothing is required that has no input.
|
||||
*/
|
||||
gaps: { name: boolean; email: boolean; phone: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the company's owner is — meaning whoever the eTrade licence names as the
|
||||
* business's manager. Not necessarily the legal owner, but the person the
|
||||
* record has to match: the backoffice's check is precisely "is this the person
|
||||
* on the licence".
|
||||
*
|
||||
* Nothing here falls back to the signed-in account. The person doing the
|
||||
* onboarding is often not the person on the licence, and stamping their name,
|
||||
* email and phone onto the owner turned three required fields into a guess
|
||||
* wearing the licence's authority.
|
||||
*/
|
||||
export default function OwnerStep({
|
||||
form,
|
||||
identity,
|
||||
etradeOwner,
|
||||
gaps,
|
||||
}: OwnerStepProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
// A verified owner's own claims outrank eTrade's record for display: the
|
||||
// government IdP is the higher-trust source, and the API locks those fields
|
||||
// to it. eTrade still supplies the name and phone when there is no
|
||||
// verification — which is the case for every company represented by a PoA.
|
||||
const ownerVerified = identity?.owner.verified ?? false;
|
||||
const nameValue = identity?.owner.name || etradeOwner?.name || "";
|
||||
const phoneValue = identity?.owner.phone || etradeOwner?.phone || "";
|
||||
const emailValue = identity?.owner.email || "";
|
||||
const nameSource = identity?.owner.name ? "Fayda" : "eTrade";
|
||||
const phoneSource = identity?.owner.phone ? "Fayda" : "eTrade";
|
||||
|
||||
// A Fayda verification that names someone other than the person on the
|
||||
// licence is the one thing this step exists to catch. Advisory here — the two
|
||||
// sources transliterate Amharic names differently, so the reviewer decides —
|
||||
// but the customer should see it now rather than be rejected later.
|
||||
const mismatch = identity?.ownerMatchesEtrade === false;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="edr-muted">
|
||||
These are the details of the person registered on your eTrade licence.
|
||||
We fill in whatever eTrade and Fayda gave us; anything they left blank
|
||||
we need from you.
|
||||
</Text>
|
||||
|
||||
{!etradeOwner && !ownerVerified && (
|
||||
<Alert color="blue" variant="light" icon={<Info size={18} />}>
|
||||
Your eTrade licence didn't list a manager, so there's nothing for us
|
||||
to prefill. Enter the details of the person registered on it.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mismatch && (
|
||||
<Alert
|
||||
color="amber"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="This doesn't match your eTrade licence"
|
||||
>
|
||||
Your licence lists{" "}
|
||||
<strong>{identity?.etradeManagerName}</strong>, but the name here is{" "}
|
||||
<strong>{nameValue}</strong>. You can continue, but our team will
|
||||
check this before approving your account — so make sure it's the
|
||||
person the licence actually names.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SourcedField label="Owner's Name" value={nameValue} source={nameSource}>
|
||||
<TextInput
|
||||
label="Owner's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.ownerName?.message}
|
||||
{...register("ownerName")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: gaps.email && gaps.phone ? 2 : 1 }} spacing="md">
|
||||
{/* eTrade never returns an email for the manager and Fayda's email
|
||||
claim is optional, so this is the field most companies actually
|
||||
type — it is required either way (`REQUIRED_COMPANY_INFO`). */}
|
||||
<SourcedField label="Owner's Email" value={emailValue} source="Fayda">
|
||||
<TextInput
|
||||
label="Owner's Email"
|
||||
type="email"
|
||||
placeholder="owner@company.com"
|
||||
error={errors.ownerEmail?.message}
|
||||
{...register("ownerEmail")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SourcedField
|
||||
label="Owner's Phone"
|
||||
value={phoneValue}
|
||||
source={phoneSource}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="ownerPhone"
|
||||
label="Owner's Phone"
|
||||
/>
|
||||
</SourcedField>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
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;
|
||||
/**
|
||||
* Which of the manager's contact details their Fayda verification did not
|
||||
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
|
||||
* requires exactly these in the schema.
|
||||
*/
|
||||
gaps: { name: boolean; email: boolean; phone: boolean };
|
||||
}
|
||||
|
||||
export default function PersonnelStep({
|
||||
form,
|
||||
identity,
|
||||
etradeOwner,
|
||||
gmSameAsOwner,
|
||||
onToggleGmSameAsOwner,
|
||||
gmLinkPending,
|
||||
gmVerified,
|
||||
gaps,
|
||||
}: 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fayda's name, email and phone claims are all optional, and the
|
||||
manager's own verification has no account to fall back on the way the
|
||||
owner's does — the person onboarding is not necessarily the manager.
|
||||
Whatever the verification left empty is typed here, and required:
|
||||
without it the submit fails on "Add your general manager name" with no
|
||||
field anywhere to satisfy it. */}
|
||||
{gaps.name && (
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
)}
|
||||
{(gaps.email || gaps.phone) && (
|
||||
<SimpleGrid cols={gaps.email && gaps.phone ? 2 : 1} spacing="md">
|
||||
{gaps.email && (
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
)}
|
||||
{gaps.phone && (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
import { Button, Divider, Group, SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||
import { Trash2 } from "lucide-react";
|
||||
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";
|
||||
import { LinkCheckboxCard } from "../LinkCheckboxCard";
|
||||
|
||||
export interface PoaStepProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
identity?: CompanyIdentityState;
|
||||
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
|
||||
requirePoa: boolean;
|
||||
/** The owner represents the company themselves. */
|
||||
poaSameAsOwner: boolean;
|
||||
onTogglePoaSameAsOwner: (checked: boolean) => void;
|
||||
/** A server-side "same as owner" declaration is in flight. */
|
||||
poaLinkPending: boolean;
|
||||
/** eTrade-registered owner, once a TIN lookup has succeeded. */
|
||||
etradeOwner: { name: string; phone: string } | null;
|
||||
/**
|
||||
* Which of the representative's details the Fayda verification did not
|
||||
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
|
||||
* requires exactly these in the schema — so every input rendered below is one
|
||||
* the customer is actually asked to fill.
|
||||
*/
|
||||
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
|
||||
/** Drop a verified representative the company decided against. */
|
||||
onRemovePoa: () => void;
|
||||
removePending: 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,
|
||||
poaSameAsOwner,
|
||||
onTogglePoaSameAsOwner,
|
||||
poaLinkPending,
|
||||
etradeOwner,
|
||||
gaps,
|
||||
onRemovePoa,
|
||||
removePending,
|
||||
delegationRequired,
|
||||
poaDocumentSetting,
|
||||
documentFiles,
|
||||
uploadedDocumentKeys,
|
||||
documentFieldErrors,
|
||||
onDocumentFilesChange,
|
||||
}: PoaStepProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
// Fayda's email/phone/address claims are optional and routinely come back
|
||||
// empty, so a *verified* representative can still be missing the email and
|
||||
// phone the API demands from a freight forwarder (`REQUIRED_POA_FIELDS`) —
|
||||
// and the panel above renders no input for them, which dead-ends the step on
|
||||
// "Add the poa email first". `gaps` is exactly what the verification did not
|
||||
// supply: the API keeps those keys typeable, since a claim that returned
|
||||
// nothing owns no value to protect (`faydaOwnedKeys`).
|
||||
const needsEmail = gaps.email;
|
||||
const needsPhone = gaps.phone;
|
||||
|
||||
// Fayda is mandatory for an Ethiopian company's representative, so there the
|
||||
// link can only reuse a proven owner — with none there would be nothing to
|
||||
// copy and the declaration could never satisfy the gate. A foreign company's
|
||||
// owner is backed by a typed passport, so it prefills instead.
|
||||
const linkNeedsVerifiedOwner =
|
||||
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
|
||||
{poaSameAsOwner
|
||||
? "You represent the company yourself, so no delegation paper is needed."
|
||||
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
|
||||
</Text>
|
||||
|
||||
{/* An owner who represents their own company is the ordinary
|
||||
small-business case. Where the owner is Fayda-verified this reuses
|
||||
that proven identity outright rather than sending the same human
|
||||
through Fayda twice; where they are backed by a typed passport there
|
||||
is nothing proven to copy, so it stays a local prefill. Either way it
|
||||
is the declaration that waives the DARS paper. */}
|
||||
{identity && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsOwner}
|
||||
onToggle={onTogglePoaSameAsOwner}
|
||||
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
|
||||
title={
|
||||
identity.owner.verified
|
||||
? "Same as verified owner"
|
||||
: "Same as business owner"
|
||||
}
|
||||
description={
|
||||
linkNeedsVerifiedOwner
|
||||
? "Verify the company owner with Fayda first — then you can reuse that identity here."
|
||||
: identity.owner.verified
|
||||
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
: etradeOwner
|
||||
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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. Verifying a second
|
||||
person is only meaningful when the representative is not the owner. */}
|
||||
{identity && !poaSameAsOwner && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
disabled={poaLinkPending}
|
||||
/>
|
||||
)}
|
||||
{/* A verification cannot be undone by clearing the form — it owns those
|
||||
fields — and its mere existence makes the delegation paper due, which
|
||||
then blocks the submit. So an optional representative needs a way
|
||||
back out, here rather than only in settings (unreachable until
|
||||
onboarding finishes). */}
|
||||
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
loading={removePending}
|
||||
leftSection={<Trash2 size={14} />}
|
||||
onClick={onRemovePoa}
|
||||
>
|
||||
Remove this representative
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{/* Whatever the Fayda claim did carry is shown on the panel above and
|
||||
is never typed here — the verification owns it. */}
|
||||
{gaps.name && (
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
)}
|
||||
{(needsEmail || needsPhone) && (
|
||||
<SimpleGrid cols={needsEmail && needsPhone ? 2 : 1} spacing="md">
|
||||
{needsEmail && (
|
||||
<TextInput
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
)}
|
||||
{needsPhone && (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
{gaps.address && (
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
Alert,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Info, UserCheck, UserX } from "lucide-react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { ControlledPhoneField } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import RoleCard from "@/pages/settings/RoleCard";
|
||||
import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import type {
|
||||
CompanyIdentityState,
|
||||
PoaDeclaration,
|
||||
} from "@/services/verifayda.service";
|
||||
|
||||
import type { FormData } from "../schema";
|
||||
import SourcedField from "../SourcedField";
|
||||
|
||||
export interface RepresentationStepProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
identity?: CompanyIdentityState;
|
||||
/** Answer the power-of-attorney question (persisted server-side). */
|
||||
onDeclare: (declared: PoaDeclaration) => void;
|
||||
/** A declaration change is in flight. */
|
||||
declarePending: boolean;
|
||||
/**
|
||||
* Which of the representative's details the Fayda verification did not
|
||||
* supply. Same contract as OwnerStep's `gaps`: an input is rendered for
|
||||
* exactly these, and the schema requires exactly these.
|
||||
*/
|
||||
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
|
||||
/** Single-field upload setting carrying just the DARS delegation letter. */
|
||||
poaDocumentSetting?: FileUploadSetting;
|
||||
documentFiles: Record<string, File | File[] | null>;
|
||||
uploadedDocumentKeys?: string[];
|
||||
documentFieldErrors: Record<string, string>;
|
||||
onDocumentFilesChange: (next: Record<string, File | File[] | null>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who acts for this company — and, as a direct consequence, whose identity gets
|
||||
* verified.
|
||||
*
|
||||
* A company proves itself through exactly one person. This step asks which:
|
||||
* name a Power of Attorney and it is the representative who verifies (plus the
|
||||
* DARS paper evidencing the delegation); say there is none and the owner
|
||||
* verifies here instead. There is no third answer — "the owner represents the
|
||||
* company themselves" IS "no".
|
||||
*
|
||||
* A freight forwarder is never asked. It signs on other companies' behalf, so a
|
||||
* representative and the paper behind them are non-negotiable; the API forces
|
||||
* the answer regardless of what the portal sends.
|
||||
*/
|
||||
export default function RepresentationStep({
|
||||
form,
|
||||
identity,
|
||||
onDeclare,
|
||||
declarePending,
|
||||
gaps,
|
||||
poaDocumentSetting,
|
||||
documentFiles,
|
||||
uploadedDocumentKeys,
|
||||
documentFieldErrors,
|
||||
onDocumentFilesChange,
|
||||
}: RepresentationStepProps) {
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = form;
|
||||
|
||||
if (!identity) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const declared = identity.poaDeclared;
|
||||
const locked = identity.poaDeclared === "yes" && identity.subject === "poa";
|
||||
// Only the API knows whether the lock is the freight-forwarder rule; it
|
||||
// reports the answer as "yes" for them no matter what is stored, so a company
|
||||
// that cannot switch to "no" is one the API will refuse. Rather than
|
||||
// duplicating the role check here, the "no" card simply reports the refusal.
|
||||
const passportAccepted = identity.passportAccepted;
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="lg" c="edr-text">
|
||||
Does anyone hold power of attorney for this company?
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Your answer decides whose identity we verify — the representative's,
|
||||
or the owner's.
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<RoleCard
|
||||
label="Yes, we have a representative"
|
||||
description="Someone other than the owner is authorised to act for the company. We'll verify their identity and ask for the DARS delegation paper."
|
||||
icon={<UserCheck size={20} />}
|
||||
selected={declared === "yes"}
|
||||
onClick={
|
||||
declarePending || declared === "yes"
|
||||
? undefined
|
||||
: () => onDeclare("yes")
|
||||
}
|
||||
/>
|
||||
<RoleCard
|
||||
label="No, the owner acts for us"
|
||||
description="Nobody holds power of attorney. We'll verify the owner instead, and no delegation paper is needed."
|
||||
icon={<UserX size={20} />}
|
||||
selected={declared === "no"}
|
||||
onClick={
|
||||
declarePending || declared === "no"
|
||||
? undefined
|
||||
: () => onDeclare("no")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
{locked && (
|
||||
<Alert color="blue" variant="light" icon={<Info size={18} />}>
|
||||
As a freight forwarder you act on other companies' behalf, so a Power
|
||||
of Attorney is required — this can't be set to "no" while you hold the
|
||||
freight forwarder role.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{declared === null && (
|
||||
<Text size="sm" c="edr-muted">
|
||||
Pick one to continue.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* No representative → the owner is the one who verifies. */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{declared === "no" && (
|
||||
<>
|
||||
<Divider />
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
state={identity.owner}
|
||||
required={!passportAccepted}
|
||||
/>
|
||||
{/* Fayda is an Ethiopian national ID, so a foreign company's owner
|
||||
may hold none — a passport number proves them instead. Offered
|
||||
alongside, not after: either one satisfies the gate. */}
|
||||
{passportAccepted && !identity.owner.verified && (
|
||||
<TextInput
|
||||
label="Owner's Passport Number"
|
||||
description="If the owner has no Fayda ID, their passport number proves their identity instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* A representative → they verify, and the delegation is evidenced. */}
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{declared === "yes" && (
|
||||
<>
|
||||
<Divider />
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={!passportAccepted}
|
||||
/>
|
||||
{passportAccepted && !identity.poa.verified && (
|
||||
<TextInput
|
||||
label="Representative's Passport Number"
|
||||
description="If your representative has no Fayda ID, their passport number proves their identity instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.poaPassportNumber?.message}
|
||||
{...register("poaPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Whatever the Fayda claim carried is shown on the panel above and
|
||||
never typed here — the verification owns it. The rest is asked
|
||||
for outright, because the API demands name/email/phone from any
|
||||
declared representative (`REQUIRED_POA_FIELDS`). */}
|
||||
<SourcedField
|
||||
label="Representative's Name"
|
||||
value={gaps.name ? "" : identity.poa.name}
|
||||
source="Fayda"
|
||||
>
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: gaps.email && gaps.phone ? 2 : 1 }}
|
||||
spacing="md"
|
||||
>
|
||||
<SourcedField
|
||||
label="Representative's Email"
|
||||
value={gaps.email ? "" : identity.poa.email}
|
||||
source="Fayda"
|
||||
>
|
||||
<TextInput
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="representative@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SourcedField
|
||||
label="Representative's Phone"
|
||||
value={gaps.phone ? "" : identity.poa.phone}
|
||||
source="Fayda"
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
</SourcedField>
|
||||
</SimpleGrid>
|
||||
|
||||
{gaps.address && (
|
||||
<TextInput
|
||||
label="Representative's Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{poaDocumentSetting && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Text size="sm" c="edr-muted">
|
||||
Upload the delegation paper authenticated by DARS. It is what
|
||||
evidences that this person was actually delegated.
|
||||
</Text>
|
||||
<SmartFileInput
|
||||
file={poaDocumentSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
errors={documentFieldErrors}
|
||||
onChange={onDocumentFilesChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -24,14 +24,12 @@ import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import ETradeInfo, {
|
||||
type ETradeStatus,
|
||||
} from "@/components/onboarding/ETradeInfo";
|
||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||
import { ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS } from "@/pages/accounts/companyProfileForm/schema";
|
||||
import { normalizeIdentityPhones } from "@/pages/accounts/companyProfileForm/helpers";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// eTrade-sourced and read-only, like the registration block below.
|
||||
@@ -149,11 +147,6 @@ export default function TabCompanyProfile({
|
||||
// Fayda stores the phone as the national registry holds it (often a local
|
||||
// number), which neither this form's E.164 validation nor the API's
|
||||
// `@IsValidPhone()` accepts. Normalize on read — same as the wizard.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile?.identity),
|
||||
[profile?.identity],
|
||||
);
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||
// typed directly.
|
||||
@@ -287,13 +280,6 @@ export default function TabCompanyProfile({
|
||||
validationError ??
|
||||
(mutation.isError ? extractApiError(mutation.error).message : null);
|
||||
|
||||
const pendingOwnerReview = Boolean(
|
||||
(
|
||||
profile?.pendingChanges as {
|
||||
faydaIdentity?: Record<string, unknown>;
|
||||
} | null
|
||||
)?.faydaIdentity?.ownerFaydaSub,
|
||||
);
|
||||
|
||||
// During onboarding the role selection gates the form: nothing else shows
|
||||
// until the user picks Importer/Exporter or Freight Forwarder.
|
||||
@@ -335,51 +321,9 @@ export default function TabCompanyProfile({
|
||||
/>
|
||||
</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}
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
status={
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Briefcase, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Card,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Grid,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// Optional, not unrequired: an Ethiopian company's GM is established through
|
||||
// Fayda and never types these, so a blanket `min(1)` would fail a form that is
|
||||
// correct. Presence is gated below, where the identity state says which route
|
||||
// applies; zod only polices format for the companies that still type them.
|
||||
const schema = z.object({
|
||||
generalManagerName: z.string().optional(),
|
||||
generalManagerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid GM email",
|
||||
),
|
||||
generalManagerPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface TabGeneralManagerProps {
|
||||
profile: ProfileResponse;
|
||||
mode?: "edit" | "onboarding";
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general manager's identity comes from Fayda: either verified in their
|
||||
* own right, or declared to be the owner — very often the same human, which is
|
||||
* what "Same as owner" is for. Typed details survive only for a foreign
|
||||
* company, whose manager may hold no Fayda ID at all.
|
||||
*/
|
||||
export default function TabGeneralManager({ profile, mode = "edit", onContinue }: TabGeneralManagerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const owner = profile.identity?.owner;
|
||||
const identity = profile.identity;
|
||||
const gm = identity?.gm;
|
||||
const faydaRequired = identity?.faydaRequired ?? false;
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(
|
||||
identity?.gmSameAsOwner ?? false,
|
||||
);
|
||||
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
generalManagerName: profile.generalManagerName ?? "",
|
||||
generalManagerEmail: profile.generalManagerEmail ?? "",
|
||||
generalManagerPhone: profile.generalManagerPhone ?? "",
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
/**
|
||||
* With a Fayda-verified owner the declaration is made server-side — the API
|
||||
* copies the proven identity onto the GM — so nothing is typed here. Without
|
||||
* one (a foreign company, whose owner is backed by a passport) there is
|
||||
* nothing proven to copy and this stays a local prefill.
|
||||
*/
|
||||
const [linkPending, setLinkPending] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const toggleGmSameAsOwner = async (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
setLinkError(null);
|
||||
if (!owner?.verified) {
|
||||
if (checked && owner) {
|
||||
setValue("generalManagerName", owner.name ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerEmail", owner.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", owner.phone ?? "", { shouldValidate: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setGmSameAsOwner();
|
||||
else await verifaydaService.clearGmIdentity();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
} catch (err) {
|
||||
setGmSameAsOwner(!checked);
|
||||
setLinkError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error ? err.message : "Could not update the general manager"),
|
||||
);
|
||||
} finally {
|
||||
setLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "generalManagerEmail must be an email". A verified manager legitimately
|
||||
// leaves the fields Fayda did supply blank here.
|
||||
generalManagerName: data.generalManagerName || undefined,
|
||||
generalManagerEmail: data.generalManagerEmail || undefined,
|
||||
generalManagerPhone: data.generalManagerPhone || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: api.companies.getProfile.queryKey() });
|
||||
if (mode === "onboarding") onContinue?.();
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => mutation.mutate(data);
|
||||
|
||||
// Nothing to save when Fayda owns the details: the verification and the
|
||||
// "same as owner" declaration both write server-side, so the form would be
|
||||
// posting empty strings over a proven identity.
|
||||
const typedFieldsInUse = !gmSameAsOwner && !gm?.verified && !faydaRequired;
|
||||
|
||||
// Except for what the verification never supplied. Fayda's name, email and
|
||||
// phone claims are all optional, and a manager verified without them has no
|
||||
// account to fall back on the way the owner does — so those stay typed, here
|
||||
// as well as in onboarding, or a wrong value could never be corrected.
|
||||
const gmGaps = {
|
||||
name: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.name?.trim(),
|
||||
email: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.email?.trim(),
|
||||
phone: !gmSameAsOwner && Boolean(gm?.verified) && !gm?.phone?.trim(),
|
||||
};
|
||||
const savable =
|
||||
typedFieldsInUse || gmGaps.name || gmGaps.email || gmGaps.phone;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<Briefcase size={20} />
|
||||
<Title order={3}>General Manager</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
Manage the general manager information
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title={
|
||||
owner?.verified ? "Same as verified owner" : "Same as business owner"
|
||||
}
|
||||
description={
|
||||
owner?.verified
|
||||
? "Reuse the Fayda-verified owner's identity for the general manager. Uncheck to verify a different person."
|
||||
: "Reuse the owner's name, email and phone. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
|
||||
{linkError && (
|
||||
<Alert color="red" variant="light" icon={<XCircle size={18} />}>
|
||||
{linkError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Verifying a second person only means something when the manager
|
||||
is someone other than the owner. */}
|
||||
{!gmSameAsOwner && gm && (
|
||||
<FaydaVerifyPanel
|
||||
subject="gm"
|
||||
title="General Manager"
|
||||
state={gm}
|
||||
required={faydaRequired}
|
||||
disabled={linkPending || mutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Whatever the verification did not supply is typed instead — the
|
||||
API keeps exactly those keys writable. */}
|
||||
{gmGaps.name && (
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
)}
|
||||
{(gmGaps.email || gmGaps.phone) && (
|
||||
<Grid>
|
||||
{gmGaps.email && (
|
||||
<Grid.Col span={gmGaps.phone ? 6 : 12}>
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
{gmGaps.phone && (
|
||||
<Grid.Col span={gmGaps.email ? 6 : 12}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* 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 and refuses edits, so they go away. */}
|
||||
{!gmSameAsOwner && !gm?.verified && !faydaRequired && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.generalManagerName?.message}
|
||||
{...register("generalManagerName")}
|
||||
/>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Email Address"
|
||||
type="email"
|
||||
placeholder="gm@company.com"
|
||||
error={errors.generalManagerEmail?.message}
|
||||
{...register("generalManagerEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="generalManagerPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
justify="space-between"
|
||||
mt="xl"
|
||||
pt="md"
|
||||
style={{ borderTop: "1px solid var(--mantine-color-edr-border-0)" }}
|
||||
>
|
||||
<Group gap="xs">
|
||||
{mutation.isSuccess && (
|
||||
<Group gap={6} c="green">
|
||||
<CheckCircle2 size={16} />
|
||||
<Text size="sm" fw={500}>Saved successfully</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>Save failed</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{mode === "edit" && typedFieldsInUse && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={mutation.isPending || !isDirty}
|
||||
onClick={() => reset()}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
{/* Saving only means something while the details are typed: under
|
||||
Fayda both routes write server-side, so a submit would post
|
||||
empty strings at an identity the API owns and refuses to
|
||||
overwrite. Onboarding still needs a way forward, so the button
|
||||
becomes a plain Continue rather than disappearing. */}
|
||||
{savable ? (
|
||||
<Button
|
||||
type="submit"
|
||||
leftSection={<Save size={16} />}
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
{mode === "onboarding" ? "Continue" : "Save Changes"}
|
||||
</Button>
|
||||
) : (
|
||||
mode === "onboarding" && (
|
||||
<Button type="button" onClick={() => onContinue?.()}>
|
||||
Continue
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
198
apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx
Normal file
198
apps/edr-freight-web/portal/src/pages/settings/TabOwner.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { AlertTriangle, Briefcase, Save } from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
|
||||
// the API and never typed here, so a blanket `min(1)` would fail a form that is
|
||||
// correct. Presence is gated below, where the identity state says which fields
|
||||
// are actually on screen; zod only polices format.
|
||||
const schema = z.object({
|
||||
ownerName: z.string().optional(),
|
||||
ownerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid owner email",
|
||||
),
|
||||
ownerPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
|
||||
interface TabOwnerProps {
|
||||
profile: ProfileResponse;
|
||||
mode?: "edit" | "onboarding";
|
||||
onContinue?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The company's owner — meaning whoever the eTrade licence names as the
|
||||
* business's manager. Not necessarily the legal owner, but the person the
|
||||
* record has to match: the backoffice's check is that comparison.
|
||||
*
|
||||
* Their identity is Fayda-verified only when the company has NO Power of
|
||||
* Attorney; when it names a representative it is the representative who
|
||||
* verifies, and the owner's details are simply recorded (from eTrade, or typed
|
||||
* here). Either way all three are required.
|
||||
*/
|
||||
export default function TabOwner({
|
||||
profile,
|
||||
mode = "edit",
|
||||
onContinue,
|
||||
}: TabOwnerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const identity = profile.identity;
|
||||
const owner = identity?.owner;
|
||||
// Only the person the declaration points at carries the verification, so the
|
||||
// panel is offered here only when that person is the owner.
|
||||
const ownerIsSubject = identity?.subject === "owner";
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
ownerName: profile.ownerName ?? "",
|
||||
ownerEmail: profile.ownerEmail ?? "",
|
||||
ownerPhone: profile.ownerPhone ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "ownerEmail must be an email". A verified owner legitimately leaves
|
||||
// the fields Fayda did supply blank here.
|
||||
ownerName: data.ownerName || undefined,
|
||||
ownerEmail: data.ownerEmail || undefined,
|
||||
ownerPhone: data.ownerPhone || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
if (mode === "onboarding") onContinue?.();
|
||||
},
|
||||
});
|
||||
|
||||
// What the verification did NOT supply. Fayda's email and phone claims are
|
||||
// optional, so a verified owner can still be missing details the API demands
|
||||
// — the API leaves exactly those keys typeable, and so does this form.
|
||||
const gaps = {
|
||||
name: !owner?.name?.trim(),
|
||||
email: !owner?.email?.trim(),
|
||||
phone: !owner?.phone?.trim(),
|
||||
};
|
||||
const savable = gaps.name || gaps.email || gaps.phone;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<Group gap="sm" mb="xs">
|
||||
<Briefcase size={20} />
|
||||
<Title order={3}>Company Owner</Title>
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
The person registered on your eTrade licence.
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
|
||||
<Stack gap="md">
|
||||
{identity?.ownerMatchesEtrade === false && (
|
||||
<Alert
|
||||
color="amber"
|
||||
variant="light"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="This doesn't match your eTrade licence"
|
||||
>
|
||||
Your licence lists <strong>{identity.etradeManagerName}</strong>.
|
||||
Our team checks this before approving changes.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{ownerIsSubject && owner && (
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Owner identity"
|
||||
state={owner}
|
||||
required={!identity?.passportAccepted}
|
||||
pendingReview={profile.reviewStatus === "pending"}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SourcedField label="Name" value={owner?.name} source="Fayda">
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.ownerName?.message}
|
||||
{...register("ownerName")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField label="Email" value={owner?.email} source="Fayda">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="owner@company.com"
|
||||
error={errors.ownerEmail?.message}
|
||||
{...register("ownerEmail")}
|
||||
/>
|
||||
</SourcedField>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField label="Phone" value={owner?.phone} source="Fayda">
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="ownerPhone"
|
||||
label="Phone"
|
||||
/>
|
||||
</SourcedField>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{savable && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
loading={mutation.isPending}
|
||||
leftSection={<Save size={16} />}
|
||||
>
|
||||
{mode === "onboarding" ? "Save & continue" : "Save changes"}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Undo2,
|
||||
UploadCloud,
|
||||
UserCheck,
|
||||
UserX,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
@@ -41,7 +43,7 @@ import {
|
||||
} from "@/services/companies.service";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||
import RoleCard from "@/pages/settings/RoleCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
@@ -136,21 +138,17 @@ export default function TabPowerOfAttorney({
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const owner = identity?.owner;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
|
||||
identity?.poaSameAsOwner ?? false,
|
||||
);
|
||||
// The paper authorises the representative named above, so there is nothing
|
||||
// for it to authorise until one has been verified — the upload is hidden
|
||||
// until then, and requiring it while hidden would block the save on a
|
||||
// control the customer cannot see. A freight forwarder is still held to
|
||||
// having a PoA at all, by the verification gate on the panel and by the API.
|
||||
//
|
||||
// And nobody delegates to themselves: an owner representing their own company
|
||||
// has no delegation to evidence, which is the same waiver the API applies in
|
||||
// `assertPoaDelegationSatisfied`.
|
||||
const letterRequired = poaProvided && !poaSameAsOwner;
|
||||
// Whether there is a representative at all is the company's own declaration,
|
||||
// held server-side — it decides whose identity the API gates on, so it is
|
||||
// never local state here.
|
||||
const declared = identity?.poaDeclared ?? null;
|
||||
// The paper is owed exactly when the company says it has a representative —
|
||||
// the same single rule `assertPoaDelegationSatisfied` enforces. Keying it on
|
||||
// the verification instead would hide the upload from a foreign company whose
|
||||
// representative proves themselves by passport, then fail the save for a file
|
||||
// that was never offered.
|
||||
const letterRequired = declared === "yes";
|
||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||
|
||||
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
||||
@@ -191,11 +189,12 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
/**
|
||||
* A verified representative cannot be removed by blanking the form — their
|
||||
* fields are owned by the verification — so removal is its own action that
|
||||
* clears the identity and the delegation paper together.
|
||||
* fields are owned by the verification — so removal is answering the
|
||||
* declaration "no", which clears the identity, the details and the
|
||||
* delegation paper together. Refused by the API for a freight forwarder.
|
||||
*/
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: () => verifaydaService.removePoa(),
|
||||
mutationFn: () => verifaydaService.setPoaDeclared("no"),
|
||||
onSuccess: () => {
|
||||
resetAll();
|
||||
queryClient.invalidateQueries({
|
||||
@@ -208,25 +207,20 @@ export default function TabPowerOfAttorney({
|
||||
});
|
||||
|
||||
/**
|
||||
* "Same as owner": the owner represents the company themselves. Always goes
|
||||
* to the API, whichever credential backs the owner — the declaration is what
|
||||
* waives the DARS paper, so it has to be recorded server-side even when there
|
||||
* is no proven identity to copy.
|
||||
* Answer the power-of-attorney question.
|
||||
*
|
||||
* Unchecking undoes the declaration only. It leaves the paper on file and is
|
||||
* allowed for a freight forwarder, which is how one changes who represents
|
||||
* it; "Remove representative" below is the harder action that takes the paper
|
||||
* with it and is refused to a forwarder.
|
||||
* "No" means the owner acts for the company themselves — there is no
|
||||
* delegation, so no DARS paper is owed and it is the OWNER whose identity is
|
||||
* verified. The API tears the representative down when this is answered, and
|
||||
* refuses "no" outright for a freight forwarder.
|
||||
*/
|
||||
const [linkPending, setLinkPending] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const togglePoaSameAsOwner = async (checked: boolean) => {
|
||||
setPoaSameAsOwner(checked);
|
||||
const declare = async (next: "yes" | "no") => {
|
||||
setLinkError(null);
|
||||
setLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setPoaSameAsOwner();
|
||||
else await verifaydaService.clearPoaSameAsOwner();
|
||||
await verifaydaService.setPoaDeclared(next);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
@@ -234,7 +228,6 @@ export default function TabPowerOfAttorney({
|
||||
queryKey: api.companies.poaDelegation.queryKey(),
|
||||
});
|
||||
} catch (err) {
|
||||
setPoaSameAsOwner(!checked);
|
||||
setLinkError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
@@ -297,10 +290,10 @@ export default function TabPowerOfAttorney({
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
|
||||
: "Power of Attorney details are optional."}{" "}
|
||||
{poaSameAsOwner
|
||||
? "You represent the company yourself, so no delegation paper is needed."
|
||||
: "If you name a representative, upload the DARS delegation paper authorising them."}
|
||||
: "Tell us whether anyone is authorised to act for the company — your answer decides whose identity we verify."}{" "}
|
||||
{declared === "no"
|
||||
? "The owner acts for the company, so no delegation paper is needed."
|
||||
: "A representative must be identified, and the DARS delegation paper authorising them uploaded."}
|
||||
</Text>
|
||||
|
||||
{/* The owner representing their own company is the ordinary
|
||||
@@ -309,30 +302,35 @@ export default function TabPowerOfAttorney({
|
||||
mandatory it needs a verified owner first — there would be nothing
|
||||
proven to copy, and a representative who could never satisfy the
|
||||
gate. */}
|
||||
{/* The declaration itself. "No" is not a lesser answer — it means the
|
||||
owner acts for the company, so it is the OWNER who verifies and no
|
||||
delegation paper is owed. A freight forwarder cannot choose it; the
|
||||
API refuses and the error lands in `linkError`. */}
|
||||
{identity && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsOwner}
|
||||
onToggle={togglePoaSameAsOwner}
|
||||
disabled={
|
||||
linkPending ||
|
||||
mutation.isPending ||
|
||||
(!poaSameAsOwner &&
|
||||
(identity.faydaRequired ?? false) &&
|
||||
!owner?.verified)
|
||||
}
|
||||
title={
|
||||
owner?.verified
|
||||
? "Same as verified owner"
|
||||
: "Same as business owner"
|
||||
}
|
||||
description={
|
||||
(identity.faydaRequired ?? false) && !owner?.verified
|
||||
? "Verify the company owner with Fayda first — then you can reuse that identity here."
|
||||
: owner?.verified
|
||||
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mb="md">
|
||||
<RoleCard
|
||||
label="Yes, we have a representative"
|
||||
description="Someone other than the owner is authorised to act for the company. Their identity is verified and the DARS delegation paper is required."
|
||||
icon={<UserCheck size={20} />}
|
||||
selected={declared === "yes"}
|
||||
onClick={
|
||||
linkPending || declared === "yes"
|
||||
? undefined
|
||||
: () => void declare("yes")
|
||||
}
|
||||
/>
|
||||
<RoleCard
|
||||
label="No, the owner acts for us"
|
||||
description="Nobody holds power of attorney. The owner's identity is verified instead, and no delegation paper is needed."
|
||||
icon={<UserX size={20} />}
|
||||
selected={declared === "no"}
|
||||
onClick={
|
||||
linkPending || declared === "no"
|
||||
? undefined
|
||||
: () => void declare("no")
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{linkError && (
|
||||
@@ -343,12 +341,12 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
{/* Verifying a second person only means something when the
|
||||
representative is someone other than the owner. */}
|
||||
{identity && !poaSameAsOwner && (
|
||||
{identity && declared === "yes" && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
required={!identity.passportAccepted}
|
||||
disabled={mutation.isPending || linkPending}
|
||||
/>
|
||||
)}
|
||||
@@ -359,7 +357,7 @@ export default function TabPowerOfAttorney({
|
||||
verification and are shown on the panel above. Only a company
|
||||
whose representative may hold no Fayda ID still types a
|
||||
location. */}
|
||||
{!poaProvided && !(identity?.faydaRequired ?? false) && (
|
||||
{declared === "yes" && !poaProvided && (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
@@ -376,7 +374,7 @@ export default function TabPowerOfAttorney({
|
||||
{/* ------------------------ Delegation letter ------------------------ */}
|
||||
{/* The paper authorises the representative the verification named,
|
||||
so it only has meaning once one exists. */}
|
||||
{poaProvided && !poaSameAsOwner && (
|
||||
{declared === "yes" && (
|
||||
<Stack gap="sm" mt="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
@@ -418,7 +416,7 @@ export default function TabPowerOfAttorney({
|
||||
>
|
||||
{requirePoa
|
||||
? "Upload the DARS delegation paper before saving — it is required for freight forwarders."
|
||||
: "Upload the DARS delegation paper for the representative you named, or clear the PoA details."}
|
||||
: "Upload the DARS delegation paper for the representative you named, or answer \u201cthe owner acts for us\u201d instead."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -551,12 +549,12 @@ export default function TabPowerOfAttorney({
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{/* Not offered against a "same as owner" declaration: unchecking
|
||||
the card above is the way out of that one, and it leaves the
|
||||
paper alone. */}
|
||||
{/* Answering "the owner acts for us" is the same teardown, so
|
||||
this is only a shortcut — and it is refused to a forwarder,
|
||||
which cannot be without a representative. */}
|
||||
{mode === "edit" &&
|
||||
identity?.poa.verified &&
|
||||
!poaSameAsOwner &&
|
||||
declared === "yes" &&
|
||||
!requirePoa && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -158,14 +158,17 @@ export interface OnboardingLicenseProfile {
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
/** Power of Attorney state — mandatory for freight forwarders, optional otherwise. */
|
||||
/** Power of Attorney state, driven by the company's own declaration. */
|
||||
export interface OnboardingPoaState {
|
||||
required: boolean;
|
||||
provided: boolean;
|
||||
/**
|
||||
* True when the DARS delegation paper is owed. False when the owner
|
||||
* represents the company themselves — nobody delegates to themselves.
|
||||
* True for a freight forwarder: it signs on other companies' behalf, so a
|
||||
* representative is non-negotiable and the question is shown answered rather
|
||||
* than asked.
|
||||
*/
|
||||
locked: boolean;
|
||||
/** The company's answer. Null until it answers — itself an outstanding item. */
|
||||
declared: "yes" | "no" | null;
|
||||
/** True when the DARS delegation paper is owed — i.e. `declared === "yes"`. */
|
||||
delegationLetterRequired: boolean;
|
||||
delegationLetterUploaded: boolean;
|
||||
/** True when a reviewer sent the DARS delegation paper back for correction. */
|
||||
@@ -189,7 +192,7 @@ export interface OnboardingRequirements {
|
||||
documents: OnboardingDocumentField[];
|
||||
licenseProfiles: OnboardingLicenseProfile[];
|
||||
poa: OnboardingPoaState;
|
||||
/** Fayda verification state; `required` is false for a foreign company. */
|
||||
/** The company's single identity verification, and whose it is. */
|
||||
identity: CompanyIdentityState;
|
||||
progress: { completed: number; total: number };
|
||||
isComplete: boolean;
|
||||
|
||||
@@ -3,14 +3,21 @@ import { unwrap } from "@/utils/endpoint";
|
||||
import type { ApiResponse } from "@/types/apiResponse";
|
||||
|
||||
/**
|
||||
* Which of the company's people a verification is for. The owner is who the
|
||||
* company is proven through; the PoA and GM are personnel it names. The GM is
|
||||
* very often the owner — "same as owner" reuses that verification rather than
|
||||
* making the same human prove themselves twice.
|
||||
* Which of the company's two people a verification is for.
|
||||
*
|
||||
* The **owner** is whoever the eTrade licence names as the business's manager
|
||||
* — not necessarily the legal owner, but the person the record has to match.
|
||||
* The **PoA** is who the company delegates to act for it.
|
||||
*
|
||||
* Exactly one of them is verified, chosen by the company's own answer to "does
|
||||
* anyone hold power of attorney for you?" — see `poaDeclared`.
|
||||
*/
|
||||
export type IdentitySubject = "owner" | "poa" | "gm";
|
||||
export type IdentitySubject = "owner" | "poa";
|
||||
|
||||
/** One person's Fayda verification state, as the API reports it. */
|
||||
/** Whether the company named a representative. Null until it answers. */
|
||||
export type PoaDeclaration = "yes" | "no";
|
||||
|
||||
/** One person's identity state, as the API reports it. */
|
||||
export interface IdentityVerificationState {
|
||||
verified: boolean;
|
||||
name: string | null;
|
||||
@@ -18,43 +25,42 @@ export interface IdentityVerificationState {
|
||||
email: string | null;
|
||||
address: string | null;
|
||||
verifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OwnerIdentityState extends IdentityVerificationState {
|
||||
/**
|
||||
* Typed passport number — the foreign-company identity credential.
|
||||
* Independent of Fayda: never written by a verification, and still required
|
||||
* even if the owner also verifies.
|
||||
* Typed passport number — the ALTERNATIVE to Fayda for a foreign company,
|
||||
* never written by a verification. Only asked of whichever person carries
|
||||
* the company's identity, and only when `passportAccepted`.
|
||||
*/
|
||||
passportNumber: string | null;
|
||||
}
|
||||
|
||||
export interface CompanyIdentityState {
|
||||
/**
|
||||
* True when Fayda verification is mandatory — Ethiopian companies only.
|
||||
* Doubles as "may this person be typed instead": Fayda is an Ethiopian
|
||||
* national ID, so a foreign company's GM and PoA are offered the
|
||||
* verification but fall back to typed details when they hold none.
|
||||
* True for a foreign company: a typed passport number proves the identity
|
||||
* just as a Fayda verification does. Fayda is an Ethiopian national ID, so an
|
||||
* Ethiopian company has no alternative to it.
|
||||
*/
|
||||
faydaRequired: boolean;
|
||||
/** True when the owner's passport number is mandatory — foreign companies only. */
|
||||
passportRequired: boolean;
|
||||
owner: OwnerIdentityState;
|
||||
passportAccepted: boolean;
|
||||
/**
|
||||
* The company's answer to the power-of-attorney question. Null until it
|
||||
* answers — which is itself outstanding, since the answer decides who
|
||||
* verifies. Always "yes" for a freight forwarder, which cannot operate
|
||||
* without a representative and is never asked.
|
||||
*/
|
||||
poaDeclared: PoaDeclaration | null;
|
||||
/** Whose verification the company is gated on. Null while undeclared. */
|
||||
subject: IdentitySubject | null;
|
||||
owner: IdentityVerificationState;
|
||||
poa: IdentityVerificationState;
|
||||
/** True once `subject` is proven — Fayda-verified, or passport where accepted. */
|
||||
identityProven: boolean;
|
||||
/** The manager named on the eTrade licence, captured at lookup. */
|
||||
etradeManagerName: string | null;
|
||||
/**
|
||||
* True when the representative is the owner themselves, declared through
|
||||
* "same as owner". Waives the DARS delegation paper — nobody delegates to
|
||||
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
|
||||
* with the owner's sub.
|
||||
* Does the owner the company put forward match the eTrade licence? This is
|
||||
* the backoffice's check. Null when there is nothing to compare. Advisory:
|
||||
* eTrade's and Fayda's transliterations rarely agree exactly.
|
||||
*/
|
||||
poaSameAsOwner: boolean;
|
||||
/**
|
||||
* General manager. `verified` covers both routes: the GM verifying in their
|
||||
* own right, and the company declaring the GM is the owner (in which case
|
||||
* `gmSameAsOwner` is set and the owner's Fayda sub backs it).
|
||||
*/
|
||||
gm: IdentityVerificationState;
|
||||
gmSameAsOwner: boolean;
|
||||
ownerMatchesEtrade: boolean | null;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
@@ -127,65 +133,19 @@ export const verifaydaService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Declare the General Manager is the company's owner, reusing the owner's
|
||||
* verified identity rather than making the same human verify twice. The copy
|
||||
* happens server-side from the stored owner identity — the portal never
|
||||
* supplies the values — and is refused until the owner is verified.
|
||||
*/
|
||||
setGmSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/gm/same-as-owner",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the GM's identity — the "same as owner" declaration or a verification
|
||||
* of their own — leaving them open to be re-established either way.
|
||||
*/
|
||||
clearGmIdentity: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/gm",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Declare the Power of Attorney is the company's owner. A Fayda-verified
|
||||
* owner's identity is copied server-side (the portal never supplies it); a
|
||||
* foreign company's owner has nothing proven to copy, so the API records the
|
||||
* declaration and the form types the representative's details as usual.
|
||||
* Answer whether anyone holds power of attorney for this company — the
|
||||
* question that decides whose identity is verified.
|
||||
*
|
||||
* Either way the declaration is what waives the DARS delegation paper.
|
||||
* Answering "no" tears the representative down server-side: their details,
|
||||
* their verification, their passport number and the DARS delegation paper.
|
||||
* Refused for a freight forwarder, which cannot operate without one.
|
||||
*/
|
||||
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/poa/same-as-owner",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Undo that declaration and the identity it copied, leaving the
|
||||
* representative open to be verified in their own right. Unlike
|
||||
* {@link removePoa} this is allowed for a freight forwarder — it is how they
|
||||
* change who represents them — and leaves the delegation paper on file.
|
||||
*/
|
||||
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/poa/same-as-owner",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney — verified identity, details and delegation
|
||||
* paper together. A verified person's fields are locked, so blanking the form
|
||||
* is no longer a way to remove them. Refused for a freight forwarder.
|
||||
*/
|
||||
removePoa: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/fayda/poa",
|
||||
setPoaDeclared: async (
|
||||
declared: PoaDeclaration,
|
||||
): Promise<CompanyIdentityState> => {
|
||||
const response = await client.patch<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/poa-declared",
|
||||
{ declared },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
@@ -30,19 +30,15 @@ export interface ProfileResponse {
|
||||
contactPersonPhone: string | null;
|
||||
/** Phone that passed SMS OTP verification (resumes the verify step's state). */
|
||||
contactVerifiedPhone: string | null;
|
||||
generalManagerName: string | null;
|
||||
generalManagerEmail: string | null;
|
||||
generalManagerPhone: string | null;
|
||||
/** The owner — whoever the eTrade licence names as the business's manager. */
|
||||
ownerName: string | null;
|
||||
ownerEmail: string | null;
|
||||
ownerPhone: string | null;
|
||||
/**
|
||||
* Fayda verification state for the owner, the PoA and the general manager.
|
||||
* `identity.faydaRequired` / `identity.passportRequired` is the
|
||||
* Ethiopian/foreign switch: an Ethiopian company verifies all three with
|
||||
* Fayda, while a foreign one proves its owner with a typed passport number
|
||||
* and may type its GM and PoA, whose holders may have no Fayda ID.
|
||||
*
|
||||
* The `generalManager*` fields above are the same person's details written
|
||||
* flat — a verification keeps them in step, since the booking, contract and
|
||||
* train-scheduling notifiers mail `generalManagerEmail` directly.
|
||||
* The company's single identity verification. `identity.subject` says whose
|
||||
* it is (the PoA when one is declared, otherwise the owner);
|
||||
* `identity.passportAccepted` is the Ethiopian/foreign switch — a foreign
|
||||
* company may prove the same person with a typed passport number instead.
|
||||
*/
|
||||
identity: CompanyIdentityState;
|
||||
poaName: string | null;
|
||||
@@ -88,14 +84,18 @@ export interface UpdateProfilePayload {
|
||||
contactPersonEmail?: string;
|
||||
contactPersonPhone?: string;
|
||||
contactVerifiedPhone?: string;
|
||||
generalManagerName?: string;
|
||||
generalManagerEmail?: string;
|
||||
generalManagerPhone?: string;
|
||||
ownerName?: string;
|
||||
ownerEmail?: string;
|
||||
ownerPhone?: string;
|
||||
poaName?: string;
|
||||
poaPhone?: string;
|
||||
poaEmail?: string;
|
||||
poaLocation?: string;
|
||||
poaAddress?: string;
|
||||
/** The owner's passport number — the foreign-company identity credential. */
|
||||
/**
|
||||
* Passport numbers — the ALTERNATIVE to Fayda for a foreign company. Only the
|
||||
* one belonging to the declared identity subject is ever collected.
|
||||
*/
|
||||
ownerPassportNumber?: string;
|
||||
poaPassportNumber?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user