From 0608f8a7eaceb3679e27ca1b558ec37ce97c313a Mon Sep 17 00:00:00 2001 From: Nathnael Date: Tue, 18 Aug 2026 11:37:58 +0000 Subject: [PATCH] fix(portal): bring the settings tabs in line with the wizard's rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/pages/settings/TabCompanyProfile.tsx | 204 ++++++++++++++++-- .../src/pages/settings/TabContactPerson.tsx | 62 ++++-- .../src/pages/settings/TabDocuments.tsx | 6 +- .../portal/src/pages/settings/TabOwner.tsx | 113 +++++++--- .../src/pages/settings/TabPowerOfAttorney.tsx | 196 ++++++++++++++--- .../settings/companyProfileSchema.test.ts | 70 ++++++ 6 files changed, 560 insertions(+), 91 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/pages/settings/companyProfileSchema.test.ts diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index a359a3ff0..f8eb58e8f 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -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; +/** + * 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([]); const [tinStatus, setTinStatus] = useState("idle"); @@ -140,7 +198,7 @@ export default function TabCompanyProfile({ setValue, formState: { errors, isDirty, dirtyFields }, } = useForm({ - resolver: zodResolver(COMPANY_PROFILE_SCHEMA), + resolver: zodResolver(buildCompanyProfileSchema(manualRegistration)), values: defaultValues, }); @@ -315,7 +373,6 @@ export default function TabCompanyProfile({ @@ -325,13 +382,21 @@ export default function TabCompanyProfile({ - {tinVerified && ( + {!manualRegistration && tinVerified && ( )} + {manualRegistration && ( + + + + )} + ; + watch: ReturnType>["watch"]; + setValue: UseFormSetValue; + errors: FieldErrors; +}) { + const region = watch("region") ?? ""; + + return ( + + + + Registered address + + +