mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(freight-portal): stop owner details vanishing between wizard steps
SourcedField rendered read-only whenever a value existed, so the input a customer had just typed into turned into a badge as soon as the step saved and they navigated back — and dropped out of requiredKeys at the same time. It now locks on ownership instead: a Fayda verification owns what its claims filled, everything else stays an editable, prefilled input. The representation step follows the same rule and asks in the right order: - The power-of-attorney question collapses to its answer once given, with a button back to it (none for a freight forwarder, whose answer is forced). - A foreign company picks how to prove the person outright — Fayda or a passport — rather than being shown both at once. - The representative's own fields appear only once the person is established, and only for what the verification did not supply; what it did supply is already on the panel above and is no longer repeated beneath it. - Dropped the freight-forwarder lecture and the DARS blurb; the badge and the upload field's own help text already say both. VAT numbers accept any non-blank value. A foreign tax authority's carries letters and dashes and a co-operative's follows neither pattern, so the 10-11 digit rule only ever rejected numbers we had no business judging.
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
MaxLength,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
Matches,
|
||||
} from "class-validator";
|
||||
import { ETHIOPIAN_REGIONS, type EthiopianRegion } from "@edr/types";
|
||||
import { CompanyNationality } from "../entities/company.entity";
|
||||
@@ -36,13 +35,14 @@ export class UpdateProfileDto {
|
||||
@IsTin({ message: "TIN must be exactly 10 digits" })
|
||||
tin?: string;
|
||||
|
||||
// Ethiopian VAT registration numbers are 10 digits (the same shape as the
|
||||
// TIN), but some are issued with an 11th. Both portal forms enforce the same
|
||||
// range; without it here the API happily stored whatever a stale client sent,
|
||||
// and the two layers disagreed about what the column may hold.
|
||||
// No shape check. Ethiopian VAT numbers are usually 10 or 11 digits, but a
|
||||
// foreign company's is whatever its own tax authority issues — letters,
|
||||
// dashes and any length — and a co-operative's registration numbering does
|
||||
// not follow the trade-licence pattern either. The field is required (the
|
||||
// portal enforces non-blank) but its content is not ours to police.
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^\d{10,11}$/, { message: "VAT number must be 10 or 11 digits" })
|
||||
@MaxLength(64)
|
||||
vatNumber?: string;
|
||||
|
||||
// `fanNumber` is deliberately absent: the FAN is a Fayda number, so it would
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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. */
|
||||
/** Where a locked value came from, shown as a badge next to it. */
|
||||
export type FieldSource = "eTrade" | "Fayda";
|
||||
|
||||
const SOURCE_NOTE: Record<FieldSource, string> = {
|
||||
@@ -10,35 +10,37 @@ const SOURCE_NOTE: Record<FieldSource, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* One person-detail field that may already be answered for us.
|
||||
* One person-detail field that a verification may have taken ownership of.
|
||||
*
|
||||
* 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.
|
||||
* `locked` — not "does a value exist" — decides which side renders. That
|
||||
* distinction is the whole point: `ownerName` holds a value the moment the
|
||||
* eTrade lookup prefills it or the customer types it and the step saves, and
|
||||
* keying off presence meant the input a customer had just filled in turned into
|
||||
* a read-only badge as soon as they navigated away and back, with no way to
|
||||
* correct it. Only a Fayda verification actually owns a field — the API refuses
|
||||
* to overwrite those — so only those lock.
|
||||
*
|
||||
* 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**.
|
||||
* It pairs with `requiredKeys` in CompanyProfileForm, which requires exactly
|
||||
* the fields that fall through to `children`: **a field is required if and only
|
||||
* if there is an input on screen to satisfy it.**
|
||||
*/
|
||||
export default function SourcedField({
|
||||
label,
|
||||
value,
|
||||
source,
|
||||
locked,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
/** The value a source supplied. Blank/absent means "ask the customer". */
|
||||
/** The value to display when locked. */
|
||||
value?: string | null;
|
||||
source: FieldSource;
|
||||
/** The input rendered when no source supplied a value. */
|
||||
/** A verification owns this field: show it read-only instead of an input. */
|
||||
locked: boolean;
|
||||
/** The input rendered whenever the field is still the customer's to fill. */
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!value?.trim()) return <>{children}</>;
|
||||
if (!locked || !value?.trim()) return <>{children}</>;
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
|
||||
@@ -14,12 +14,15 @@ export interface OwnerStepProps {
|
||||
/** 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.
|
||||
* Which of the owner's details a Fayda verification owns. A locked field is
|
||||
* shown read-only; every other one is an editable input, prefilled from
|
||||
* eTrade or from what was saved earlier. CompanyProfileForm computes this and
|
||||
* requires exactly the unlocked fields, so every input on screen is one the
|
||||
* customer is actually asked to fill and nothing is required that has none.
|
||||
*/
|
||||
gaps: { name: boolean; email: boolean; phone: boolean };
|
||||
locked: { name: boolean; email: boolean; phone: boolean };
|
||||
/** A co-operative union or farm: no licence, so no eTrade record to match. */
|
||||
cooperative?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,12 +35,17 @@ export interface OwnerStepProps {
|
||||
* 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.
|
||||
*
|
||||
* A co-operative union or farm has no licence, so there is nobody named on one
|
||||
* — the owner is simply the person who runs it, typed in full and compared
|
||||
* against nothing.
|
||||
*/
|
||||
export default function OwnerStep({
|
||||
form,
|
||||
identity,
|
||||
etradeOwner,
|
||||
gaps,
|
||||
locked,
|
||||
cooperative = false,
|
||||
}: OwnerStepProps) {
|
||||
const {
|
||||
register,
|
||||
@@ -45,16 +53,7 @@ export default function OwnerStep({
|
||||
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
|
||||
@@ -65,12 +64,12 @@ export default function OwnerStep({
|
||||
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.
|
||||
{cooperative
|
||||
? "The person who runs the co-operative union or farm. We have no licence record to fill these in from, so we need all of them from you."
|
||||
: "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 && (
|
||||
{!cooperative && !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.
|
||||
@@ -84,15 +83,19 @@ export default function OwnerStep({
|
||||
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.
|
||||
Your licence lists <strong>{identity?.etradeManagerName}</strong>, but
|
||||
the name here is <strong>{identity?.owner.name}</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}>
|
||||
<SourcedField
|
||||
label="Owner's Name"
|
||||
value={identity?.owner.name}
|
||||
source="Fayda"
|
||||
locked={locked.name}
|
||||
>
|
||||
<TextInput
|
||||
label="Owner's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
@@ -101,11 +104,16 @@ export default function OwnerStep({
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: gaps.email && gaps.phone ? 2 : 1 }} spacing="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} 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">
|
||||
<SourcedField
|
||||
label="Owner's Email"
|
||||
value={identity?.owner.email}
|
||||
source="Fayda"
|
||||
locked={locked.email}
|
||||
>
|
||||
<TextInput
|
||||
label="Owner's Email"
|
||||
type="email"
|
||||
@@ -117,8 +125,9 @@ export default function OwnerStep({
|
||||
|
||||
<SourcedField
|
||||
label="Owner's Phone"
|
||||
value={phoneValue}
|
||||
source={phoneSource}
|
||||
value={identity?.owner.phone}
|
||||
source="Fayda"
|
||||
locked={locked.phone}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Info, UserCheck, UserX } from "lucide-react";
|
||||
import { IdCard, ShieldCheck, UserCheck, UserX } from "lucide-react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
@@ -24,6 +25,9 @@ import type {
|
||||
import type { FormData } from "../schema";
|
||||
import SourcedField from "../SourcedField";
|
||||
|
||||
/** How a foreign company chose to prove the person: Fayda, or a passport. */
|
||||
export type IdentityMethod = "fayda" | "passport";
|
||||
|
||||
export interface RepresentationStepProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
identity?: CompanyIdentityState;
|
||||
@@ -32,11 +36,26 @@ export interface RepresentationStepProps {
|
||||
/** 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.
|
||||
* The answer is not the company's to change — it operates as a freight
|
||||
* forwarder, which the API forces to "yes". No alert says so: the summary
|
||||
* simply offers no way back, which is the same information without the
|
||||
* lecture.
|
||||
*/
|
||||
gaps: { name: boolean; email: boolean; phone: boolean; address: boolean };
|
||||
declarationLocked: boolean;
|
||||
/**
|
||||
* Which of the representative's details the Fayda verification owns. Same
|
||||
* contract as OwnerStep's `locked`: a locked field is read-only, every other
|
||||
* one is an input, and the schema requires exactly the unlocked ones.
|
||||
*/
|
||||
locked: { name: boolean; email: boolean; phone: boolean; address: boolean };
|
||||
/**
|
||||
* How a foreign company is proving the subject. Null until it picks — the
|
||||
* either/or is a fork, not a fallback, so nothing below it renders until one
|
||||
* side is chosen. Always "fayda" for an Ethiopian company, which has no
|
||||
* choice to make.
|
||||
*/
|
||||
method: IdentityMethod | null;
|
||||
onMethodChange: (method: IdentityMethod) => void;
|
||||
/** Single-field upload setting carrying just the DARS delegation letter. */
|
||||
poaDocumentSetting?: FileUploadSetting;
|
||||
documentFiles: Record<string, File | File[] | null>;
|
||||
@@ -64,7 +83,10 @@ export default function RepresentationStep({
|
||||
identity,
|
||||
onDeclare,
|
||||
declarePending,
|
||||
gaps,
|
||||
declarationLocked,
|
||||
locked,
|
||||
method,
|
||||
onMethodChange,
|
||||
poaDocumentSetting,
|
||||
documentFiles,
|
||||
uploadedDocumentKeys,
|
||||
@@ -86,184 +108,220 @@ export default function RepresentationStep({
|
||||
}
|
||||
|
||||
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;
|
||||
const subject = declared === "yes" ? identity.poa : identity.owner;
|
||||
const verified = subject.verified;
|
||||
|
||||
// Nothing below the fork renders until the person is actually established:
|
||||
// a Fayda claim that came back, or the passport path deliberately chosen.
|
||||
// Asking for a name before the verification runs is asking for a value the
|
||||
// verification is about to overwrite.
|
||||
const established = verified || method === "passport";
|
||||
|
||||
const who = declared === "yes" ? "Representative" : "Owner";
|
||||
const passportField =
|
||||
declared === "yes" ? "poaPassportNumber" : "ownerPassportNumber";
|
||||
|
||||
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" && (
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* The question. Once answered it collapses to its answer, so the step */}
|
||||
{/* is about the person rather than re-presenting a settled choice. */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{declared === null ? (
|
||||
<>
|
||||
<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")}
|
||||
<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={false}
|
||||
onClick={declarePending ? undefined : () => onDeclare("yes")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---------------------------------------------------------------- */}
|
||||
{/* 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")}
|
||||
<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={false}
|
||||
onClick={declarePending ? undefined : () => onDeclare("no")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
) : (
|
||||
<ChoiceSummary
|
||||
icon={declared === "yes" ? <UserCheck size={18} /> : <UserX size={18} />}
|
||||
label={
|
||||
declared === "yes"
|
||||
? "A representative holds power of attorney"
|
||||
: "The owner acts for the company"
|
||||
}
|
||||
detail={
|
||||
declared === "yes"
|
||||
? "We'll verify their identity and ask for the DARS delegation paper."
|
||||
: "Nobody holds power of attorney, so we verify the owner."
|
||||
}
|
||||
onChange={
|
||||
declarePending || declarationLocked
|
||||
? undefined
|
||||
: () => onDeclare(declared === "yes" ? "no" : "yes")
|
||||
}
|
||||
changeLabel={declared === "yes" ? "We have no representative" : "We have a representative"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{gaps.address && (
|
||||
<TextInput
|
||||
label="Representative's Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
{declared !== null && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{/* -------------------------------------------------------------- */}
|
||||
{/* How the person is proved. Ethiopian: Fayda, no choice. Foreign: */}
|
||||
{/* Fayda or a passport — one or the other, picked outright. */}
|
||||
{/* -------------------------------------------------------------- */}
|
||||
{passportAccepted && !verified && method === null ? (
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} c="edr-text">
|
||||
How would you like to prove {who.toLowerCase()}'s identity?
|
||||
</Text>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Either one is enough — you don't need both.
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md" mt="xs">
|
||||
<RoleCard
|
||||
label="Verify with Fayda"
|
||||
description="Their name, phone and address come straight from the national ID — nothing to type."
|
||||
icon={<ShieldCheck size={20} />}
|
||||
selected={false}
|
||||
onClick={() => onMethodChange("fayda")}
|
||||
/>
|
||||
<RoleCard
|
||||
label="Use a passport instead"
|
||||
description="For someone who holds no Fayda ID. You'll enter their passport number and details yourself."
|
||||
icon={<IdCard size={20} />}
|
||||
selected={false}
|
||||
onClick={() => onMethodChange("passport")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{(method === "fayda" || !passportAccepted || verified) && (
|
||||
<FaydaVerifyPanel
|
||||
subject={declared === "yes" ? "poa" : "owner"}
|
||||
title={declared === "yes" ? "Power of Attorney" : "Company owner"}
|
||||
state={subject}
|
||||
required={!passportAccepted}
|
||||
/>
|
||||
)}
|
||||
|
||||
{passportAccepted && !verified && method === "passport" && (
|
||||
<TextInput
|
||||
label={`${who}'s Passport Number`}
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors[passportField]?.message}
|
||||
{...register(passportField)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{passportAccepted && !verified && method !== null && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
style={{ alignSelf: "flex-start" }}
|
||||
onClick={() =>
|
||||
onMethodChange(method === "fayda" ? "passport" : "fayda")
|
||||
}
|
||||
>
|
||||
{method === "fayda"
|
||||
? "Use a passport instead"
|
||||
: "Verify with Fayda instead"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{poaDocumentSetting && (
|
||||
{/* -------------------------------------------------------------- */}
|
||||
{/* The representative's own details — only once the person exists, */}
|
||||
{/* and only the parts the verification did not already carry. What */}
|
||||
{/* Fayda supplied is shown on the panel above and never repeated. */}
|
||||
{/* -------------------------------------------------------------- */}
|
||||
{declared === "yes" && established && (
|
||||
<>
|
||||
<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}
|
||||
/>
|
||||
<SourcedField
|
||||
label="Representative's Name"
|
||||
value={identity.poa.name}
|
||||
source="Fayda"
|
||||
locked={locked.name}
|
||||
>
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<SourcedField
|
||||
label="Representative's Email"
|
||||
value={identity.poa.email}
|
||||
source="Fayda"
|
||||
locked={locked.email}
|
||||
>
|
||||
<TextInput
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="representative@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
</SourcedField>
|
||||
|
||||
<SourcedField
|
||||
label="Representative's Phone"
|
||||
value={identity.poa.phone}
|
||||
source="Fayda"
|
||||
locked={locked.phone}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
</SourcedField>
|
||||
</SimpleGrid>
|
||||
|
||||
{!locked.address && (
|
||||
<TextInput
|
||||
label="Representative's Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{poaDocumentSetting && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<SmartFileInput
|
||||
file={poaDocumentSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
errors={documentFieldErrors}
|
||||
onChange={onDocumentFilesChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -271,3 +329,41 @@ export default function RepresentationStep({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** A settled choice, shown as its answer with a way back to the question. */
|
||||
function ChoiceSummary({
|
||||
icon,
|
||||
label,
|
||||
detail,
|
||||
onChange,
|
||||
changeLabel,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
detail: string;
|
||||
onChange?: () => void;
|
||||
changeLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<Card padding="md" radius="md" withBorder>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Group gap="sm" align="flex-start" wrap="nowrap">
|
||||
<span style={{ display: "flex", marginTop: 2 }}>{icon}</span>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted">
|
||||
{detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{onChange && (
|
||||
<Button variant="subtle" size="compact-xs" onClick={onChange}>
|
||||
{changeLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,15 @@ export default function TabOwner({
|
||||
// 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";
|
||||
// A Fayda verification owns what its claims filled — the API refuses to
|
||||
// overwrite those, so they show read-only. Anything it left blank stays
|
||||
// editable here, whatever value is currently stored.
|
||||
const ownerVerified = owner?.verified ?? false;
|
||||
const ownerLocked = {
|
||||
name: ownerVerified && Boolean(owner?.name?.trim()),
|
||||
email: ownerVerified && Boolean(owner?.email?.trim()),
|
||||
phone: ownerVerified && Boolean(owner?.phone?.trim()),
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -147,7 +156,12 @@ export default function TabOwner({
|
||||
/>
|
||||
)}
|
||||
|
||||
<SourcedField label="Name" value={owner?.name} source="Fayda">
|
||||
<SourcedField
|
||||
label="Name"
|
||||
value={owner?.name}
|
||||
source="Fayda"
|
||||
locked={ownerLocked.name}
|
||||
>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
@@ -158,7 +172,12 @@ export default function TabOwner({
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField label="Email" value={owner?.email} source="Fayda">
|
||||
<SourcedField
|
||||
label="Email"
|
||||
value={owner?.email}
|
||||
source="Fayda"
|
||||
locked={ownerLocked.email}
|
||||
>
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
@@ -169,7 +188,12 @@ export default function TabOwner({
|
||||
</SourcedField>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField label="Phone" value={owner?.phone} source="Fayda">
|
||||
<SourcedField
|
||||
label="Phone"
|
||||
value={owner?.phone}
|
||||
source="Fayda"
|
||||
locked={ownerLocked.phone}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="ownerPhone"
|
||||
|
||||
@@ -288,9 +288,11 @@ export default function TabPowerOfAttorney({
|
||||
)}
|
||||
</Group>
|
||||
<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."
|
||||
: "Tell us whether anyone is authorised to act for the company — your answer decides whose identity we verify."}{" "}
|
||||
{/* The "Required for freight forwarder" badge above already says why
|
||||
a forwarder has no choice here; repeating it in prose was a
|
||||
lecture, not information. */}
|
||||
{!requirePoa &&
|
||||
"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."}
|
||||
|
||||
Reference in New Issue
Block a user