mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(freight-portal): gate company step on etrade verification
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Button, Group, Loader, Stack, TextInput } from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
|
||||
import { AlertCircle, Download } from "lucide-react";
|
||||
import { useETradeData } from "@/hooks/useETradeData";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
export type ETradeStatus =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "verified"
|
||||
| "not-found"
|
||||
| "taken"
|
||||
| "error";
|
||||
|
||||
interface ETradeInfoProps {
|
||||
/** Current TIN value (drives button enablement). */
|
||||
tin: string;
|
||||
@@ -22,6 +22,8 @@ interface ETradeInfoProps {
|
||||
/** Validation error for the TIN field, if any. */
|
||||
error?: string;
|
||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
/** Reports the live lookup status so the parent step can gate on it. */
|
||||
onStatusChange?: (status: ETradeStatus) => void;
|
||||
}
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
@@ -31,12 +33,11 @@ export default function ETradeInfo({
|
||||
register,
|
||||
error,
|
||||
onDataLoaded,
|
||||
onStatusChange,
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
const tinTaken = mutation.data?.tinTaken;
|
||||
const hasData =
|
||||
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!isValidTin(tin)) return;
|
||||
@@ -47,8 +48,10 @@ export default function ETradeInfo({
|
||||
};
|
||||
|
||||
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch.
|
||||
const lastFetchedTin = useRef<string | null>(null);
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch. Seeded
|
||||
// from the initial value so a resumed draft with an already-verified TIN
|
||||
// doesn't refire the lookup the moment this mounts.
|
||||
const lastFetchedTin = useRef<string | null>(tin || null);
|
||||
useEffect(() => {
|
||||
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
||||
lastFetchedTin.current = tin;
|
||||
@@ -61,16 +64,36 @@ export default function ETradeInfo({
|
||||
mutation.isError && mutation.error
|
||||
? extractApiError(mutation.error)
|
||||
: null;
|
||||
// A 400 here means eTrade simply has no record for this TIN — not a
|
||||
// failure. Soft-pedal it as an FYI, not a red error, so filling in
|
||||
// manually doesn't feel like something went wrong.
|
||||
// A 400 here means eTrade simply has no record for this TIN.
|
||||
const notFound = apiError?.statusCode === 400;
|
||||
const errorMessage =
|
||||
apiError && !notFound
|
||||
? apiError.message ||
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again."
|
||||
: null;
|
||||
|
||||
const status: ETradeStatus = isLoading
|
||||
? "loading"
|
||||
: tinTaken
|
||||
? "taken"
|
||||
: mutation.isSuccess && mutation.data && !mutation.data.tinTaken
|
||||
? "verified"
|
||||
: notFound
|
||||
? "not-found"
|
||||
: errorMessage
|
||||
? "error"
|
||||
: "idle";
|
||||
|
||||
const lastReportedStatus = useRef<ETradeStatus | null>(null);
|
||||
useEffect(() => {
|
||||
if (lastReportedStatus.current === status) return;
|
||||
lastReportedStatus.current = status;
|
||||
onStatusChange?.(status);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status]);
|
||||
|
||||
const showRetry = isValidTin(tin) && status !== "verified" && status !== "loading";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
@@ -86,7 +109,7 @@ export default function ETradeInfo({
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
{errorMessage && (
|
||||
{showRetry && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
@@ -103,9 +126,13 @@ export default function ETradeInfo({
|
||||
</Group>
|
||||
|
||||
{notFound && (
|
||||
<Alert icon={<Info size={16} />} color="gray">
|
||||
We couldn't find a matching business record for this TIN — no
|
||||
problem, just fill in the details below.
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="No matching business record"
|
||||
>
|
||||
This TIN isn't registered with eTrade. Check the number — we can't
|
||||
continue without a matching business record.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -130,29 +157,6 @@ export default function ETradeInfo({
|
||||
mistake.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<Alert
|
||||
icon={<CheckCircle2 size={16} />}
|
||||
color="green"
|
||||
title="Company information loaded"
|
||||
>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">
|
||||
<strong>License:</strong> {hasData.licenceNumber}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
<strong>Status:</strong> {hasData.statusDescription}
|
||||
</Text>
|
||||
{hasData.region && (
|
||||
<Text size="sm">
|
||||
<strong>Location:</strong> {hasData.kebele}, {hasData.woreda},{" "}
|
||||
{hasData.zone}, {hasData.region}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +66,8 @@ const STEP_META: Record<
|
||||
company: {
|
||||
icon: <Building2 size={20} />,
|
||||
title: "Company Information",
|
||||
description: "Tell us about your company and its registration details.",
|
||||
description:
|
||||
"Confirm your VAT number, verify the owner's identity, and we'll pull your registration from eTrade.",
|
||||
},
|
||||
personnel: {
|
||||
icon: <User size={20} />,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -14,13 +13,12 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { CreateCompanyPayload } from "@/services/companies.service";
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
@@ -28,7 +26,9 @@ import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
} from "@/components/onboarding/RoleLicenseStep";
|
||||
import ETradeInfo from "@/components/onboarding/ETradeInfo";
|
||||
import ETradeInfo, {
|
||||
type ETradeStatus,
|
||||
} from "@/components/onboarding/ETradeInfo";
|
||||
import {
|
||||
buildOnboardingSchema,
|
||||
type CompanyStep,
|
||||
@@ -45,7 +45,8 @@ import {
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "./companyProfileForm/LinkCheckboxCard";
|
||||
import { ReadOnlyField } from "./companyProfileForm/ReadOnlyField";
|
||||
import ETradeCompanyCard from "./companyProfileForm/ETradeCompanyCard";
|
||||
import StepSection from "./companyProfileForm/StepSection";
|
||||
|
||||
export default function CompanyProfileForm({
|
||||
documentSettingCode,
|
||||
@@ -113,6 +114,9 @@ export default function CompanyProfileForm({
|
||||
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
// Live eTrade lookup status, reported up by ETradeInfo — drives the Continue
|
||||
// gate on the company step.
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
// Report each step change up so the wizard can persist it for resume.
|
||||
useEffect(() => {
|
||||
@@ -197,7 +201,6 @@ export default function CompanyProfileForm({
|
||||
companyName: "",
|
||||
companyEmail: "",
|
||||
companyPhone: "",
|
||||
companyLocation: "",
|
||||
companyAddress: "",
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
@@ -230,14 +233,9 @@ export default function CompanyProfileForm({
|
||||
values: rehydrate ? toFormValues(rehydrate) : undefined,
|
||||
});
|
||||
|
||||
// eTrade carries no email, so the company/contact email fields start blank.
|
||||
// Seed them from the registering user's account email — but only while empty,
|
||||
// so a typed or rehydrated value is never overwritten.
|
||||
// The contact person's email still just seeds from the account and stays editable.
|
||||
useEffect(() => {
|
||||
if (!user?.email) return;
|
||||
if (!watch("companyEmail")) {
|
||||
setValue("companyEmail", user.email, { shouldValidate: true });
|
||||
}
|
||||
if (!watch("contactPersonEmail")) {
|
||||
setValue("contactPersonEmail", user.email);
|
||||
}
|
||||
@@ -282,19 +280,10 @@ export default function CompanyProfileForm({
|
||||
setValue("woreda", data.woreda);
|
||||
setValue("kebele", data.kebele);
|
||||
setValue("houseNo", data.houseNo);
|
||||
setValue(
|
||||
"companyPhone",
|
||||
toEthiopianE164(data.regularPhone || data.mobilePhone),
|
||||
);
|
||||
// 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.
|
||||
|
||||
// Pre-fill the company contact phone from eTrade's mobile number.
|
||||
const mobile = toEthiopianE164(data.mobilePhone || data.regularPhone);
|
||||
if (mobile) {
|
||||
setValue("companyPhone", mobile, { shouldValidate: true });
|
||||
}
|
||||
// compose it here. companyPhone is derived below (identity → eTrade →
|
||||
// account), not set directly here.
|
||||
|
||||
setEtradeOwner({
|
||||
name: data.managerName,
|
||||
@@ -304,6 +293,29 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
// companyEmail/companyPhone are no longer typed — the Fayda-verified owner
|
||||
// is the highest-trust source (that's the whole point of verifying), eTrade's
|
||||
// registered number and the account email/phone are the fallbacks used
|
||||
// before verification happens.
|
||||
useEffect(() => {
|
||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.email, user.email]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(
|
||||
"companyPhone",
|
||||
identity?.owner.phone ??
|
||||
etradeOwner?.phone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"",
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.phone, etradeOwner?.phone, user.phoneNumber]);
|
||||
|
||||
// "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.
|
||||
@@ -369,10 +381,9 @@ export default function CompanyProfileForm({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [contactSameAsGm, gmName, gmEmail, gmPhone]);
|
||||
|
||||
// The contact-person step has no location/address of its own, so the linked
|
||||
// PoA takes the company's — location as entered, address as composed from the
|
||||
// company's address fields. Both stay mirrored while the link is checked.
|
||||
const companyLocation = watch("companyLocation");
|
||||
// The contact-person step has no address of its own, so the linked PoA takes
|
||||
// the company's composed address. poaLocation (the city) stays typed on the
|
||||
// PoA step — the company step no longer has a location field to mirror.
|
||||
const companyAddress = watch("companyAddress");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -380,7 +391,6 @@ export default function CompanyProfileForm({
|
||||
setValue("poaName", contactName ?? "");
|
||||
setValue("poaEmail", contactEmail ?? "");
|
||||
setValue("poaPhone", contactPhone ?? "");
|
||||
setValue("poaLocation", companyLocation ?? "");
|
||||
setValue("poaAddress", companyAddress ?? "");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
@@ -388,7 +398,6 @@ export default function CompanyProfileForm({
|
||||
contactName,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
companyLocation,
|
||||
companyAddress,
|
||||
]);
|
||||
|
||||
@@ -425,11 +434,11 @@ export default function CompanyProfileForm({
|
||||
() =>
|
||||
uploadSetting
|
||||
? {
|
||||
...uploadSetting,
|
||||
fields: uploadSetting.fields.filter(
|
||||
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||
),
|
||||
}
|
||||
...uploadSetting,
|
||||
fields: uploadSetting.fields.filter(
|
||||
(f) => f.fileKey !== POA_DELEGATION_FILE_KEY,
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
[uploadSetting],
|
||||
);
|
||||
@@ -522,6 +531,9 @@ export default function CompanyProfileForm({
|
||||
"renewedTo",
|
||||
]);
|
||||
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
||||
// A previously-saved (rehydrated) TIN counts as verified without a refetch —
|
||||
// the registration fields being populated at all is proof it passed before.
|
||||
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
|
||||
|
||||
// Single source of truth for step sequence — navigation, labels and the
|
||||
// progress bar all derive from this so adding/removing a step is one edit.
|
||||
@@ -575,7 +587,10 @@ export default function CompanyProfileForm({
|
||||
if (step === "documents") {
|
||||
const docErrors = validateRequiredDocuments();
|
||||
const licenseErrors = validateLicenses();
|
||||
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
|
||||
if (
|
||||
Object.keys(docErrors).length > 0 ||
|
||||
Object.keys(licenseErrors).length > 0
|
||||
) {
|
||||
setDocumentFieldErrors(docErrors);
|
||||
setLicenseFieldErrors(licenseErrors);
|
||||
setSaveError("Please upload all required documents before continuing.");
|
||||
@@ -599,21 +614,30 @@ export default function CompanyProfileForm({
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
}
|
||||
// The owner's identity is proved outside the form state too, so it gates
|
||||
// here rather than through zod.
|
||||
if (
|
||||
step === "company" &&
|
||||
identity &&
|
||||
((identity.faydaRequired && !identity.owner.verified) ||
|
||||
(identity.passportRequired && !identity.owner.passportNumber))
|
||||
) {
|
||||
// The TIN must resolve to a real eTrade record before anything else on
|
||||
// this step is even worth validating — gates here rather than through zod.
|
||||
if (step === "company" && tinStatus === "taken") {
|
||||
setSaveError(
|
||||
identity.faydaRequired
|
||||
? "Verify the company owner's identity with Fayda before continuing."
|
||||
: "Add the company owner's passport number before continuing.",
|
||||
"This TIN is already registered to another company account.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (step === "company" && !tinVerified) {
|
||||
setSaveError(
|
||||
"We need to confirm your TIN with eTrade before continuing.",
|
||||
);
|
||||
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) {
|
||||
setSaveError("Verify the company owner's identity with Fayda before continuing.");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
step === "poa" &&
|
||||
verifiedIdentity &&
|
||||
@@ -657,167 +681,97 @@ export default function CompanyProfileForm({
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Stack gap="md">
|
||||
{step === "company" && (
|
||||
<Stack gap="sm">
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Stack gap="xl">
|
||||
<StepSection
|
||||
index={1}
|
||||
title="VAT number"
|
||||
status={
|
||||
watch("vatNumber")?.length === 10 && !errors.vatNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
label="VAT Number"
|
||||
placeholder="0012345678"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="VAT-12345"
|
||||
maxLength={10}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</StepSection>
|
||||
|
||||
{identity && (
|
||||
<>
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Owner identity"
|
||||
subtitle={
|
||||
verifiedIdentity
|
||||
? "Verify the company owner with Fayda — their name, phone, email and address come from the verification."
|
||||
: "Provide 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"
|
||||
}
|
||||
>
|
||||
{identity && (
|
||||
<>
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
onVerified={() => onIdentityChange?.()}
|
||||
/>
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
label="Owner Passport Number"
|
||||
placeholder="P1234567"
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Company TIN"
|
||||
subtitle="We'll pull your registration straight from eTrade — nothing to type by hand once it's found."
|
||||
status={
|
||||
tinVerified
|
||||
? "done"
|
||||
: tinStatus === "taken"
|
||||
? "blocked"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
/>
|
||||
{tinVerified && (
|
||||
<ETradeCompanyCard
|
||||
tin={watch("tinNumber")}
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
control={control}
|
||||
/>
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
label="Owner Passport Number"
|
||||
placeholder="P1234567"
|
||||
description="Fayda is an Ethiopian national ID, so a foreign company's owner is identified by passport instead."
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasRegistrationDetails && (
|
||||
<>
|
||||
<Divider my="sm" />
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Registration Details
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
from eTrade · read-only
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReadOnlyField
|
||||
label="License Number"
|
||||
value={watch("licenceNumber")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Status"
|
||||
value={watch("statusDescription")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Date Registered"
|
||||
value={watch("dateRegistered")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Renewal Date"
|
||||
value={watch("renewalDate")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Renewed From"
|
||||
value={watch("renewedFrom")}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
label="Renewed To"
|
||||
value={watch("renewedTo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider my="sm" />
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
Address Information
|
||||
</Text>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Region"
|
||||
placeholder="Select region"
|
||||
required
|
||||
searchable
|
||||
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||
error={errors.region?.message}
|
||||
// "" (unresolved eTrade value, or a legacy row whose
|
||||
// region isn't in the list) must read as "nothing picked".
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Zone"
|
||||
placeholder="EASTERN TIGRAY"
|
||||
required
|
||||
error={errors.zone?.message}
|
||||
{...register("zone")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="Woreda"
|
||||
placeholder="EROB"
|
||||
required
|
||||
error={errors.woreda?.message}
|
||||
{...register("woreda")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele"
|
||||
placeholder="ARAS"
|
||||
required
|
||||
error={errors.kebele?.message}
|
||||
{...register("kebele")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="House No"
|
||||
placeholder="House Number"
|
||||
required
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</StepSection>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
@@ -940,42 +894,42 @@ export default function CompanyProfileForm({
|
||||
/>
|
||||
)}
|
||||
{!verifiedIdentity && (
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
<>
|
||||
<TextInput
|
||||
label="PoA Name"
|
||||
placeholder="Authorized Representative Name"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Email"
|
||||
type="email"
|
||||
placeholder="poa@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="PoA Phone"
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
/>
|
||||
<TextInput
|
||||
label="PoA Address"
|
||||
placeholder="Full Address"
|
||||
error={errors.poaAddress?.message}
|
||||
{...register("poaAddress")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</>
|
||||
)}
|
||||
{/* The city is the one field the Fayda address claim does not
|
||||
reliably decompose into, so it stays typed either way. */}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Badge, Card, Group, Select, SimpleGrid, Text, TextInput } from "@mantine/core";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import type {
|
||||
Control,
|
||||
FieldErrors,
|
||||
UseFormRegister,
|
||||
UseFormWatch,
|
||||
} from "react-hook-form";
|
||||
import { ETHIOPIAN_REGIONS } from "@edr/types";
|
||||
|
||||
import type { FormData } from "./schema";
|
||||
import { ReadOnlyField } from "./ReadOnlyField";
|
||||
|
||||
/**
|
||||
* One field of the verified-registration card: locked read-only once eTrade
|
||||
* supplied a value, but falls back to an editable input when eTrade left it
|
||||
* blank — otherwise a gap in eTrade's own data would leave the field
|
||||
* permanently empty and the user stuck (zod requires all of these).
|
||||
*/
|
||||
function LockedField({
|
||||
label,
|
||||
name,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
}: {
|
||||
label: string;
|
||||
name: keyof FormData;
|
||||
register: UseFormRegister<FormData>;
|
||||
watch: UseFormWatch<FormData>;
|
||||
errors: FieldErrors<FormData>;
|
||||
}) {
|
||||
const value = watch(name) as string | undefined;
|
||||
if (value && value.trim()) {
|
||||
return <ReadOnlyField label={label} value={value} />;
|
||||
}
|
||||
return (
|
||||
<TextInput
|
||||
label={label}
|
||||
description="eTrade didn't provide this — please confirm"
|
||||
error={errors[name]?.message as string | undefined}
|
||||
{...register(name)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ETradeCompanyCard({
|
||||
tin,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
control,
|
||||
}: {
|
||||
tin: string;
|
||||
register: UseFormRegister<FormData>;
|
||||
watch: UseFormWatch<FormData>;
|
||||
errors: FieldErrors<FormData>;
|
||||
control: Control<FormData>;
|
||||
}) {
|
||||
const companyName = watch("companyName");
|
||||
const region = watch("region");
|
||||
|
||||
return (
|
||||
<Card padding="md" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Group gap="sm">
|
||||
<Text fw={600} c="edr-text">
|
||||
{companyName && companyName.trim() ? companyName : "Company record"}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="green"
|
||||
leftSection={<CheckCircle2 size={11} />}
|
||||
>
|
||||
Verified with eTrade
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="edr-muted">
|
||||
TIN {tin}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<LockedField
|
||||
label="Company Name"
|
||||
name="companyName"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<ReadOnlyField label="License Number" value={watch("licenceNumber")} />
|
||||
<ReadOnlyField label="Status" value={watch("statusDescription")} />
|
||||
<ReadOnlyField label="Date Registered" value={watch("dateRegistered")} />
|
||||
<ReadOnlyField label="Renewal Date" value={watch("renewalDate")} />
|
||||
<ReadOnlyField label="Renewed From" value={watch("renewedFrom")} />
|
||||
<ReadOnlyField label="Renewed To" value={watch("renewedTo")} />
|
||||
{region && region.trim() ? (
|
||||
<ReadOnlyField label="Region" value={region} />
|
||||
) : (
|
||||
<Controller
|
||||
name="region"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
label="Region"
|
||||
description="eTrade didn't provide this — please confirm"
|
||||
placeholder="Select region"
|
||||
searchable
|
||||
data={ETHIOPIAN_REGIONS.map((r) => ({ value: r, label: r }))}
|
||||
error={errors.region?.message}
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<LockedField
|
||||
label="Zone"
|
||||
name="zone"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<LockedField
|
||||
label="Woreda"
|
||||
name="woreda"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<LockedField
|
||||
label="Kebele"
|
||||
name="kebele"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
<LockedField
|
||||
label="House No"
|
||||
name="houseNo"
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Badge, Group, Stack, Text } from "@mantine/core";
|
||||
import { Check, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SectionStatus = "todo" | "done" | "blocked";
|
||||
|
||||
const STATUS_BADGE: Record<
|
||||
SectionStatus,
|
||||
{ color: string; label: string; icon?: ReactNode } | null
|
||||
> = {
|
||||
todo: null,
|
||||
done: { color: "green", label: "Done", icon: <Check size={11} /> },
|
||||
blocked: { color: "red", label: "Action needed", icon: <X size={11} /> },
|
||||
};
|
||||
|
||||
/**
|
||||
* One numbered section of the Company Information step — a title, an
|
||||
* optional subtitle, a status badge, and its content. Purely presentational;
|
||||
* the parent decides each section's status.
|
||||
*/
|
||||
export default function StepSection({
|
||||
index,
|
||||
title,
|
||||
subtitle,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status: SectionStatus;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const badge = STATUS_BADGE[status];
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm" align="center">
|
||||
<Text
|
||||
fw={700}
|
||||
size="sm"
|
||||
c={status === "done" ? "edr-green" : "edr-text"}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: "1.5px solid var(--mantine-color-edr-border-0)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{index}
|
||||
</Text>
|
||||
<div>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text size="xs" c="edr-muted">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{badge && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={badge.color}
|
||||
leftSection={badge.icon}
|
||||
>
|
||||
{badge.label}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<div style={{ paddingLeft: 34 }}>
|
||||
<Stack gap="sm">{children}</Stack>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export function buildPayload(
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
vatNumber: data.vatNumber,
|
||||
@@ -58,7 +57,6 @@ export function stepPayload(
|
||||
companyName: d.companyName,
|
||||
companyEmail: d.companyEmail,
|
||||
companyPhone: d.companyPhone,
|
||||
companyLocation: d.companyLocation,
|
||||
companyAddress: d.companyAddress,
|
||||
tin: d.tinNumber,
|
||||
vatNumber: d.vatNumber,
|
||||
@@ -110,7 +108,6 @@ export function toFormValues(p: ProfileResponse): FormData {
|
||||
companyName: p.companyName ?? "",
|
||||
companyEmail: p.companyEmail ?? "",
|
||||
companyPhone: p.companyPhone ?? "",
|
||||
companyLocation: p.companyLocation ?? "",
|
||||
companyAddress: p.companyAddress ?? "",
|
||||
tinNumber: tin,
|
||||
vatNumber: p.vatNumber ?? "",
|
||||
|
||||
@@ -18,7 +18,6 @@ export const onboardingSchema = z.object({
|
||||
.string()
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
// Derived from the eTrade address parts (kebele/woreda/zone/region); no
|
||||
// standalone input — the granular fields live in the registration section.
|
||||
companyAddress: z.string().optional(),
|
||||
@@ -154,7 +153,6 @@ export const stepFields: Record<CompanyStep, (keyof FormData)[]> = {
|
||||
"companyName",
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyLocation",
|
||||
"companyAddress",
|
||||
"tinNumber",
|
||||
"vatNumber",
|
||||
|
||||
Reference in New Issue
Block a user