mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(portal): bring the settings tabs in line with the wizard's rules
The Company, Owner, Contact and Power of Attorney tabs had drifted from the onboarding wizard while the manual-registration routes were added, and each divergence locked somebody out of their own account: - VAT was validated as 10-or-11 digits here and by onboarding's rule there. A foreign company's VAT is its own tax authority's and a co-operative's follows neither, so the stricter copy failed every save on the tab, not just the VAT field. The schema now reuses onboarding's, with a test pinning both rules. - Fayda's phone claims are normalized on read, as the wizard does — the registry holds local numbers that neither the form nor the API's @IsValidPhone() accepts. - The eTrade manager is shown read-only, because that record is exactly what the backoffice checks the company against; leaving it editable let a customer overwrite the value under review. - A co-operative's per-role licence cards are hidden on the Documents tab. The API lifts the requirement, so the slots could never be filled or cleared.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -20,18 +21,22 @@ import {
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
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 {
|
||||
ETRADE_BUNDLE_FIELDS as SHARED_ETRADE_FIELDS,
|
||||
onboardingSchema,
|
||||
} from "@/pages/accounts/companyProfileForm/schema";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
const BASE_COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// eTrade-sourced and read-only, like the registration block below.
|
||||
companyName: z.string().optional(),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
@@ -39,12 +44,13 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
// no standalone input.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
// Same rule as onboarding — the two forms write the same column, so they must
|
||||
// not disagree about what is acceptable in it.
|
||||
vatNumber: z
|
||||
.string()
|
||||
.min(1, "VAT number is required")
|
||||
.regex(/^\d{10,11}$/, "VAT number must be 10 or 11 digits"),
|
||||
// Onboarding's own rule, reused rather than restated: the two forms write the
|
||||
// same column, and the copy here had drifted into a 10-or-11-digit check that
|
||||
// onboarding and the API both refuse to make. A foreign company's VAT is
|
||||
// whatever its tax authority issues and a co-operative's follows neither, so
|
||||
// the stricter copy locked those customers out of their own Company tab
|
||||
// entirely — every save on it, not just the VAT.
|
||||
vatNumber: onboardingSchema.shape.vatNumber,
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
// Registration/address fields are eTrade-sourced and never typed by hand —
|
||||
// not even when eTrade leaves one blank, so none of them may be required
|
||||
@@ -62,7 +68,48 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
houseNo: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
/**
|
||||
* The registration fields a manual-registration company types by hand.
|
||||
*
|
||||
* eTrade holds no record for a co-operative union or farm, nor for a foreign
|
||||
* investor on an Investment Commission licence, so the block every other
|
||||
* company gets read-only off the licence is typed by these two — and is
|
||||
* therefore required of them, exactly as the onboarding wizard requires it.
|
||||
* House number stays optional: plenty of addresses have none.
|
||||
*/
|
||||
const TYPED_REGISTRATION_LABELS = {
|
||||
companyName: "Company name",
|
||||
region: "Region",
|
||||
zone: "Zone",
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `manualRegistration` is the only thing that changes here, and it changes the
|
||||
* same way it does in the wizard: **a field is required iff there is an input
|
||||
* on screen for it.** For an eTrade company these are read-only rows, so
|
||||
* requiring one would be a Save button failing on a field with nothing to fix.
|
||||
*/
|
||||
export const buildCompanyProfileSchema = (manualRegistration: boolean) =>
|
||||
manualRegistration
|
||||
? BASE_COMPANY_PROFILE_SCHEMA.superRefine((d, ctx) => {
|
||||
for (const key of Object.keys(
|
||||
TYPED_REGISTRATION_LABELS,
|
||||
) as (keyof typeof TYPED_REGISTRATION_LABELS)[]) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${TYPED_REGISTRATION_LABELS[key]} is required`,
|
||||
});
|
||||
}
|
||||
})
|
||||
: BASE_COMPANY_PROFILE_SCHEMA;
|
||||
|
||||
export type CompanyProfileFormData = z.infer<
|
||||
typeof BASE_COMPANY_PROFILE_SCHEMA
|
||||
>;
|
||||
|
||||
/**
|
||||
* `etradePhone` is not on this form, so the shared list is filtered down to the
|
||||
@@ -86,6 +133,17 @@ export default function TabCompanyProfile({
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
/**
|
||||
* eTrade has nothing to say about this company — a co-operative holds no
|
||||
* business licence, a foreign investor's is the Investment Commission's — so
|
||||
* its registration was typed during onboarding and has to stay editable here.
|
||||
* Rendering the eTrade card instead left that data invisible and frozen: the
|
||||
* one company that owns its registration details was the one that could not
|
||||
* see them.
|
||||
*/
|
||||
const manualRegistration = Boolean(
|
||||
profile?.cooperative || profile?.investorLicence,
|
||||
);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
@@ -140,7 +198,7 @@ export default function TabCompanyProfile({
|
||||
setValue,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<CompanyProfileFormData>({
|
||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||
resolver: zodResolver(buildCompanyProfileSchema(manualRegistration)),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
@@ -315,7 +373,6 @@ export default function TabCompanyProfile({
|
||||
<TextInput
|
||||
label="VAT Number"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={11}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
@@ -325,13 +382,21 @@ export default function TabCompanyProfile({
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Company TIN"
|
||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
subtitle={
|
||||
manualRegistration
|
||||
? "We'll check eTrade for your TIN. If it holds nothing — the usual case here — the details below stay yours to state."
|
||||
: "Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
}
|
||||
status={
|
||||
tinVerified
|
||||
? "done"
|
||||
: tinStatus === "taken"
|
||||
? "blocked"
|
||||
: "todo"
|
||||
tinStatus === "taken"
|
||||
? "blocked"
|
||||
: manualRegistration
|
||||
? watch("tinNumber")?.trim() && !errors.tinNumber
|
||||
? "done"
|
||||
: "todo"
|
||||
: tinVerified
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<ETradeInfo
|
||||
@@ -342,12 +407,33 @@ export default function TabCompanyProfile({
|
||||
onStatusChange={setTinStatus}
|
||||
alreadyVerified={hasRegistrationDetails}
|
||||
selectedLicenceNumber={watch("licenceNumber")}
|
||||
registrationOptional={manualRegistration}
|
||||
/>
|
||||
{tinVerified && (
|
||||
{!manualRegistration && tinVerified && (
|
||||
<EtradeLockedCard tin={watch("tinNumber")} watch={watch} />
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
{manualRegistration && (
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Registration details"
|
||||
subtitle="Everything we'd normally read off an eTrade licence. Our team checks what you state here against the documents you upload."
|
||||
status={
|
||||
watch("companyName")?.trim() && watch("region")?.trim()
|
||||
? "done"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<TypedRegistrationFields
|
||||
register={register}
|
||||
watch={watch}
|
||||
setValue={setValue}
|
||||
errors={errors}
|
||||
/>
|
||||
</StepSection>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
@@ -409,6 +495,86 @@ export default function TabCompanyProfile({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The registration a manual-registration company states for itself.
|
||||
*
|
||||
* Deliberately editable, unlike `EtradeLockedCard` below: nothing here came
|
||||
* from a licence, so there is no verified record to protect — it is the
|
||||
* customer's own claim, checked by a reviewer against the papers they upload.
|
||||
* Same fields, same rules and same region list as the onboarding wizard's
|
||||
* company step, so a co-operative or investor sees one story in both places.
|
||||
*/
|
||||
function TypedRegistrationFields({
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
errors,
|
||||
}: {
|
||||
register: UseFormRegister<CompanyProfileFormData>;
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
setValue: UseFormSetValue<CompanyProfileFormData>;
|
||||
errors: FieldErrors<CompanyProfileFormData>;
|
||||
}) {
|
||||
const region = watch("region") ?? "";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Registered name of the company"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<Text size="sm" c="edr-muted">
|
||||
Registered address
|
||||
</Text>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Select
|
||||
label="Region"
|
||||
placeholder="Select region"
|
||||
// A value eTrade (or an earlier save) supplied may not be spelled the
|
||||
// way our list spells it. Carrying it in as an option keeps it visible
|
||||
// rather than silently blanking a field nobody touched.
|
||||
data={
|
||||
region && !(ETHIOPIAN_REGIONS as readonly string[]).includes(region)
|
||||
? [...ETHIOPIAN_REGIONS, region]
|
||||
: [...ETHIOPIAN_REGIONS]
|
||||
}
|
||||
searchable
|
||||
value={region || null}
|
||||
onChange={(v) =>
|
||||
setValue("region", v ?? "", {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
error={errors.region?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Zone"
|
||||
error={errors.zone?.message}
|
||||
{...register("zone")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Woreda"
|
||||
error={errors.woreda?.message}
|
||||
{...register("woreda")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Kebele"
|
||||
error={errors.kebele?.message}
|
||||
{...register("kebele")}
|
||||
/>
|
||||
<TextInput
|
||||
label="House No."
|
||||
error={errors.houseNo?.message}
|
||||
{...register("houseNo")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified eTrade record, rendered strictly read-only — same rule as
|
||||
* onboarding's ETradeCompanyCard: nothing here is typeable, not even a field
|
||||
|
||||
@@ -5,20 +5,30 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { CheckCircle2, Save, User, XCircle } from "lucide-react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Title,
|
||||
Text,
|
||||
TextInput,
|
||||
Button,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The same four fields onboarding collects, with the same requiredness. The
|
||||
// position and the email were captured by the wizard and then had no input
|
||||
// here at all — stored, invisible, and impossible to correct.
|
||||
const schema = z.object({
|
||||
contactPersonName: z.string().min(1, "Contact person name is required"),
|
||||
contactPersonPosition: z.string().optional(),
|
||||
contactPersonEmail: z
|
||||
.string()
|
||||
.email("Invalid email address")
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
contactPersonPhone: z
|
||||
.string()
|
||||
.min(1, "Contact person phone is required")
|
||||
@@ -39,6 +49,8 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
const defaultValues = useMemo((): FormData => {
|
||||
return {
|
||||
contactPersonName: profile.contactPersonName ?? "",
|
||||
contactPersonPosition: profile.contactPersonPosition ?? "",
|
||||
contactPersonEmail: profile.contactPersonEmail ?? "",
|
||||
contactPersonPhone: profile.contactPersonPhone ?? "",
|
||||
};
|
||||
}, [profile]);
|
||||
@@ -58,6 +70,11 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
mutationFn: (data: FormData) =>
|
||||
api.companies.updateProfile.call({
|
||||
contactPersonName: data.contactPersonName,
|
||||
// `|| undefined`, never "": the DTO's `@IsOptional()` only skips null
|
||||
// and undefined, so an empty string is validated and 400s with
|
||||
// "contactPersonEmail must be an email".
|
||||
contactPersonPosition: data.contactPersonPosition || undefined,
|
||||
contactPersonEmail: data.contactPersonEmail || undefined,
|
||||
contactPersonPhone: data.contactPersonPhone,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
@@ -80,19 +97,36 @@ export default function TabContactPerson({ profile, mode = "edit", onContinue }:
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Full Name"
|
||||
placeholder="Jane Smith"
|
||||
error={errors.contactPersonName?.message}
|
||||
{...register("contactPersonName")}
|
||||
/>
|
||||
<TextInput
|
||||
label="Position (Optional)"
|
||||
placeholder="Operations Lead"
|
||||
error={errors.contactPersonPosition?.message}
|
||||
{...register("contactPersonPosition")}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
label="Email (Optional)"
|
||||
type="email"
|
||||
placeholder="contact@company.com"
|
||||
error={errors.contactPersonEmail?.message}
|
||||
{...register("contactPersonEmail")}
|
||||
/>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="contactPersonPhone"
|
||||
label="Phone Number"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
|
||||
@@ -171,7 +171,11 @@ export default function TabDocuments({
|
||||
return errs;
|
||||
};
|
||||
|
||||
const licenseProfiles = profile.companyProfiles;
|
||||
// A co-operative union or farm holds no business licence — that is the whole
|
||||
// reason it uploads its own document set instead — so the API lifts the
|
||||
// per-role requirement and these cards are not shown. Offering an upload slot
|
||||
// nothing can ever fill reads as an outstanding task that cannot be finished.
|
||||
const licenseProfiles = profile.cooperative ? [] : profile.companyProfiles;
|
||||
|
||||
// Company documents render through SmartFileInput, which is keyed by field and
|
||||
// has no per-file review slot. Surfacing the outstanding corrections as one
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
@@ -19,6 +20,11 @@ import { api } from "@/services/api";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import SourcedField from "@/pages/accounts/companyProfileForm/SourcedField";
|
||||
import {
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
resolveOwnerSources,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// Optional, not unrequired: whatever a Fayda verification supplied is owned by
|
||||
@@ -27,6 +33,10 @@ import type { ProfileResponse } from "@/types/profile";
|
||||
// are actually on screen; zod only polices format.
|
||||
const schema = z.object({
|
||||
ownerName: z.string().optional(),
|
||||
// The alternative credential for a foreign company's owner — asked for only
|
||||
// when they are the identity subject and have not verified with Fayda, so
|
||||
// like the three below it is optional here and gated on what is rendered.
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
ownerEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -64,20 +74,41 @@ export default function TabOwner({
|
||||
onContinue,
|
||||
}: TabOwnerProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const identity = profile.identity;
|
||||
// Fayda reports a phone as the national registry holds it, routinely a local
|
||||
// number that neither this form nor the API's `@IsValidPhone()` accepts.
|
||||
// Normalize once on read, exactly as the wizard does.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile.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";
|
||||
// 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()),
|
||||
};
|
||||
|
||||
/**
|
||||
* The manager the eTrade licence names, read back from what the lookup
|
||||
* captured. The wizard shows these read-only for the same reason this tab
|
||||
* must: that record is what the backoffice checks the company against, so it
|
||||
* is reported, not retyped. Leaving it editable here let a customer overwrite
|
||||
* the very value the review compares against.
|
||||
*/
|
||||
const etradeOwner = useMemo(() => {
|
||||
const name = identity?.etradeManagerName?.trim() ?? "";
|
||||
// Dropped if it cannot normalize — eTrade's is free text ("09 " is a
|
||||
// real answer), and an unusable number must fall through to an input rather
|
||||
// than lock the field behind a value the API would reject.
|
||||
const phone = firstValidPhone(identity?.etradeManagerPhone);
|
||||
return name || phone ? { name, phone } : null;
|
||||
}, [identity?.etradeManagerName, identity?.etradeManagerPhone]);
|
||||
|
||||
// Who owns each field, and with what value — the wizard's own rule, reused so
|
||||
// the two cannot disagree about which details are the customer's to edit.
|
||||
const { source: ownerSource, sourced: ownerSourced } = resolveOwnerSources(
|
||||
identity,
|
||||
etradeOwner,
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -90,19 +121,23 @@ export default function TabOwner({
|
||||
ownerName: profile.ownerName ?? "",
|
||||
ownerEmail: profile.ownerEmail ?? "",
|
||||
ownerPhone: profile.ownerPhone ?? "",
|
||||
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
// A field a source owns is submitted as that source has it, not as the
|
||||
// form happens to hold it: the read-only row is what the customer was
|
||||
// shown, and only the fields are sent. `|| undefined`, never "": the
|
||||
// DTO's `@IsOptional()` skips null and undefined, so an empty string is
|
||||
// validated and 400s with "ownerEmail must be an email", and a verified
|
||||
// owner legitimately leaves the fields Fayda supplied blank here.
|
||||
ownerName: (ownerSourced.name || data.ownerName) || undefined,
|
||||
ownerEmail: (ownerSourced.email || data.ownerEmail) || undefined,
|
||||
ownerPhone: (ownerSourced.phone || data.ownerPhone) || undefined,
|
||||
ownerPassportNumber: data.ownerPassportNumber || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -112,15 +147,21 @@ export default function TabOwner({
|
||||
},
|
||||
});
|
||||
|
||||
// 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;
|
||||
// A foreign owner who is the identity subject and has not verified proves
|
||||
// themselves with a passport instead — the same either/or the wizard offers.
|
||||
// Without an input here the only route to it was the onboarding wizard, which
|
||||
// an onboarded company can no longer reach.
|
||||
const passportAskable =
|
||||
(identity?.passportAccepted ?? false) && ownerIsSubject && !ownerVerified;
|
||||
|
||||
// Save is offered when there is at least one input on screen to save. A field
|
||||
// an outside source owns has none, so an owner fully supplied by Fayda and
|
||||
// eTrade has nothing to submit.
|
||||
const savable =
|
||||
!ownerSource.name ||
|
||||
!ownerSource.email ||
|
||||
!ownerSource.phone ||
|
||||
passportAskable;
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
@@ -158,8 +199,8 @@ export default function TabOwner({
|
||||
|
||||
<SourcedField
|
||||
label="Name"
|
||||
value={owner?.name}
|
||||
source={ownerLocked.name ? "Fayda" : null}
|
||||
value={ownerSourced.name}
|
||||
source={ownerSource.name}
|
||||
>
|
||||
<TextInput
|
||||
label="Name"
|
||||
@@ -173,8 +214,8 @@ export default function TabOwner({
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField
|
||||
label="Email"
|
||||
value={owner?.email}
|
||||
source={ownerLocked.email ? "Fayda" : null}
|
||||
value={ownerSourced.email}
|
||||
source={ownerSource.email}
|
||||
>
|
||||
<TextInput
|
||||
label="Email"
|
||||
@@ -188,8 +229,8 @@ export default function TabOwner({
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<SourcedField
|
||||
label="Phone"
|
||||
value={owner?.phone}
|
||||
source={ownerLocked.phone ? "Fayda" : null}
|
||||
value={ownerSourced.phone}
|
||||
source={ownerSource.phone}
|
||||
>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
@@ -200,6 +241,16 @@ export default function TabOwner({
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{passportAskable && (
|
||||
<TextInput
|
||||
label="Owner's Passport Number"
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.ownerPassportNumber?.message}
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{savable && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
|
||||
@@ -42,18 +42,68 @@ import {
|
||||
type LicenseFileStatus,
|
||||
} from "@/services/companies.service";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import {
|
||||
firstValidEmail,
|
||||
firstValidPhone,
|
||||
normalizeIdentityPhones,
|
||||
} from "@/pages/accounts/companyProfileForm/helpers";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import RoleCard from "@/pages/settings/RoleCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
// Fayda verification — a PoA is always an Ethiopian holding one — so the city
|
||||
// is the only detail this form owns.
|
||||
const schema = z.object({
|
||||
poaLocation: z.string().optional(),
|
||||
});
|
||||
/** The representative's details that a Fayda verification may or may not own. */
|
||||
const POA_LABELS = {
|
||||
poaName: "Representative's name",
|
||||
poaEmail: "Representative's email",
|
||||
poaPhone: "Representative's phone",
|
||||
} as const;
|
||||
|
||||
type FormData = z.infer<typeof schema>;
|
||||
type PoaField = keyof typeof POA_LABELS;
|
||||
|
||||
/**
|
||||
* The representative's details.
|
||||
*
|
||||
* Optional here, not unrequired. Whatever their Fayda verification supplied is
|
||||
* owned by the API and shown read-only, so a blanket `min(1)` would fail a form
|
||||
* that is correct — but Fayda's email and phone claims are optional and
|
||||
* routinely come back empty, and the API's own `REQUIRED_POA_FIELDS` demands
|
||||
* all three once a representative is declared. So requiredness is decided per
|
||||
* render, exactly as the wizard decides it: **a field is required iff there is
|
||||
* an input on screen for it.** This form used to assume the verification always
|
||||
* supplied everything and rendered no inputs at all, which left a company
|
||||
* reported incomplete with nowhere to fix it.
|
||||
*/
|
||||
const buildSchema = (required: readonly PoaField[]) =>
|
||||
z
|
||||
.object({
|
||||
poaName: z.string().optional(),
|
||||
poaEmail: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(v) => !v || z.string().email().safeParse(v).success,
|
||||
"Invalid email address",
|
||||
),
|
||||
poaPhone: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine((v) => !v || isValidPhone(v), "Enter a valid phone number"),
|
||||
poaPassportNumber: z.string().optional(),
|
||||
poaLocation: z.string().optional(),
|
||||
})
|
||||
.superRefine((d, ctx) => {
|
||||
for (const key of required) {
|
||||
if (d[key]?.trim()) continue;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [key],
|
||||
message: `${POA_LABELS[key]} is required`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof buildSchema>>;
|
||||
|
||||
const LETTER_ACCEPT = ".pdf,.png,.jpg,.jpeg";
|
||||
|
||||
@@ -96,18 +146,63 @@ export default function TabPowerOfAttorney({
|
||||
const { view, viewer } = useFileViewer();
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fayda holds a phone as the national registry does, often a local number the
|
||||
// form's E.164 validation would reject. Normalize on read, as the wizard does.
|
||||
const identity = useMemo(
|
||||
() => normalizeIdentityPhones(profile.identity),
|
||||
[profile.identity],
|
||||
);
|
||||
const poa = identity?.poa;
|
||||
const poaVerified = poa?.verified ?? false;
|
||||
|
||||
/**
|
||||
* Which details the verification owns. Same test as the wizard's: presence is
|
||||
* not enough for an email or a phone, because Fayda's claims are free text and
|
||||
* one the schema would reject is not a claim an input can be hidden behind.
|
||||
*/
|
||||
const locked = {
|
||||
name: poaVerified && Boolean(poa?.name?.trim()),
|
||||
email: poaVerified && Boolean(firstValidEmail(poa?.email)),
|
||||
phone: poaVerified && Boolean(firstValidPhone(poa?.phone)),
|
||||
};
|
||||
|
||||
const declaredYes = (identity?.poaDeclared ?? null) === "yes";
|
||||
const passportAccepted = identity?.passportAccepted ?? false;
|
||||
/**
|
||||
* The person exists to describe. Before a verification lands there is nothing
|
||||
* to attach details to — and asking for a name the verification is about to
|
||||
* overwrite is the trap the wizard avoids by the same rule. A foreign company
|
||||
* whose representative may hold no Fayda ID is the exception: the passport is
|
||||
* the proof, so its details are typed from the start.
|
||||
*/
|
||||
const established = declaredYes && (poaVerified || passportAccepted);
|
||||
|
||||
const requiredPoaFields: PoaField[] = [];
|
||||
if (established) {
|
||||
if (!locked.name) requiredPoaFields.push("poaName");
|
||||
if (!locked.email) requiredPoaFields.push("poaEmail");
|
||||
if (!locked.phone) requiredPoaFields.push("poaPhone");
|
||||
}
|
||||
|
||||
const defaultValues = useMemo(
|
||||
(): FormData => ({ poaLocation: profile.poaLocation ?? "" }),
|
||||
(): FormData => ({
|
||||
poaName: profile.poaName ?? "",
|
||||
poaEmail: profile.poaEmail ?? "",
|
||||
poaPhone: profile.poaPhone ?? "",
|
||||
poaPassportNumber: profile.identity?.poa.passportNumber ?? "",
|
||||
poaLocation: profile.poaLocation ?? "",
|
||||
}),
|
||||
[profile],
|
||||
);
|
||||
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(buildSchema(requiredPoaFields)),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
@@ -133,12 +228,6 @@ export default function TabPowerOfAttorney({
|
||||
const requirePoa = profile.companyProfiles.some(
|
||||
(p) => p.type === "freight_forwarder",
|
||||
);
|
||||
// No company types its representative's details — they come from the Fayda
|
||||
// verification whatever the nationality, since a representative acts for the
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
// 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.
|
||||
@@ -155,9 +244,17 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: FormData) => {
|
||||
// Every identity field except the city is written by the verification, so
|
||||
// only the paper and the location are ever saved here.
|
||||
const fields = { poaLocation: data.poaLocation || undefined };
|
||||
// Whatever the verification did NOT supply is this form's to save, plus
|
||||
// the paper. A field it owns is legitimately blank here (there is no
|
||||
// input), and `|| undefined` keeps that blank out of the payload — the
|
||||
// DTO's `@IsOptional()` skips null and undefined, never "".
|
||||
const fields = {
|
||||
poaName: data.poaName || undefined,
|
||||
poaEmail: data.poaEmail || undefined,
|
||||
poaPhone: data.poaPhone || undefined,
|
||||
poaPassportNumber: data.poaPassportNumber || undefined,
|
||||
poaLocation: data.poaLocation || undefined,
|
||||
};
|
||||
// A fresh upload already stages the removal of every paper on file, so
|
||||
// the explicit removals only need applying when no replacement was
|
||||
// picked. Saving the details after it means the API sees the new paper.
|
||||
@@ -355,15 +452,62 @@ export default function TabPowerOfAttorney({
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{/* Name, email, phone and address all come from the Fayda
|
||||
verification and are shown on the panel above. Only a company
|
||||
whose representative may hold no Fayda ID still types a
|
||||
location. */}
|
||||
{declared === "yes" && !poaProvided && (
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
{/* Whatever the verification supplied is on the panel above; only
|
||||
what it left blank is asked for here. A foreign representative
|
||||
who holds no Fayda ID proves themselves by passport instead —
|
||||
the same either/or the onboarding wizard offers, which an
|
||||
onboarded company can no longer reach. */}
|
||||
{established && passportAccepted && !poaVerified && (
|
||||
<TextInput
|
||||
label="Representative's Passport Number"
|
||||
description="Fayda is an Ethiopian national ID, so a passport number proves this person instead."
|
||||
placeholder="P1234567"
|
||||
error={errors.poaPassportNumber?.message}
|
||||
{...register("poaPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{established && !locked.name && (
|
||||
<TextInput
|
||||
label="Representative's Name"
|
||||
placeholder="Abebe Bikila"
|
||||
error={errors.poaName?.message}
|
||||
{...register("poaName")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{established && (!locked.email || !locked.phone) && (
|
||||
<SimpleGrid
|
||||
cols={{ base: 1, sm: !locked.email && !locked.phone ? 2 : 1 }}
|
||||
spacing="md"
|
||||
>
|
||||
{!locked.email && (
|
||||
<TextInput
|
||||
label="PoA Location"
|
||||
label="Representative's Email"
|
||||
type="email"
|
||||
placeholder="representative@company.com"
|
||||
error={errors.poaEmail?.message}
|
||||
{...register("poaEmail")}
|
||||
/>
|
||||
)}
|
||||
{!locked.phone && (
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="poaPhone"
|
||||
label="Representative's Phone"
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
|
||||
{/* The company's own statement of where the representative is
|
||||
based — never Fayda's `poaAddress`, which the portal does not
|
||||
send — so it stays typeable however well Fayda knows them. */}
|
||||
{established && (
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, sm: 6 }}>
|
||||
<TextInput
|
||||
label="Representative's Location"
|
||||
placeholder="City, Country"
|
||||
error={errors.poaLocation?.message}
|
||||
{...register("poaLocation")}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCompanyProfileSchema } from "./TabCompanyProfile";
|
||||
|
||||
/**
|
||||
* The two rules this schema exists to keep in step with onboarding: what counts
|
||||
* as a VAT number, and who has to type their registration.
|
||||
*/
|
||||
const base = {
|
||||
companyName: "Acme plc",
|
||||
companyLocation: "Addis Ababa",
|
||||
companyAddress: "",
|
||||
tinNumber: "0012345678",
|
||||
vatNumber: "0012345678",
|
||||
region: "Addis Ababa",
|
||||
zone: "Bole",
|
||||
woreda: "03",
|
||||
kebele: "07",
|
||||
houseNo: "",
|
||||
};
|
||||
|
||||
describe("buildCompanyProfileSchema", () => {
|
||||
it("accepts a VAT number no Ethiopian format rule would allow", () => {
|
||||
// The whole point of reusing onboarding's rule: a foreign company's VAT is
|
||||
// its own tax authority's, and the stricter copy locked it out of the tab.
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
vatNumber: "GB123456789",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("still requires a VAT number", () => {
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
vatNumber: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
});
|
||||
|
||||
it("does not require the registration block off an eTrade company", () => {
|
||||
// It is read-only for them, so requiring it would fail a save on a field
|
||||
// with no input to fix it.
|
||||
const parsed = buildCompanyProfileSchema(false).safeParse({
|
||||
...base,
|
||||
companyName: "",
|
||||
region: "",
|
||||
zone: "",
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires it of a company that types it by hand", () => {
|
||||
const parsed = buildCompanyProfileSchema(true).safeParse({
|
||||
...base,
|
||||
region: "",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
expect(parsed.error?.issues.map((i) => i.path[0])).toContain("region");
|
||||
});
|
||||
|
||||
it("leaves the house number optional either way", () => {
|
||||
expect(
|
||||
buildCompanyProfileSchema(true).safeParse({ ...base, houseNo: "" })
|
||||
.success,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user