mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: customer settings fix
This commit is contained in:
@@ -192,9 +192,20 @@ export class CompaniesController {
|
||||
@Post("fetch-etrade-info")
|
||||
@ApiOperation({ summary: "Fetch company info from eTrade by TIN" })
|
||||
async fetchETradeInfo(
|
||||
@CurrentUser() user: CurrentIamUser,
|
||||
@Body() dto: FetchETradeDto,
|
||||
): Promise<ETradeResponseDto> {
|
||||
const data = await this.companiesService.fetchETradeData(dto.tin);
|
||||
// Best-effort: a first-run onboarding draft may not exist yet, in which
|
||||
// case there is no company to exclude and `tinTaken` checks every row —
|
||||
// the correct behaviour for a brand-new lookup.
|
||||
const companyId = await this.companiesService
|
||||
.getCompanyInfoByUserId(user.id)
|
||||
.then(({ company }) => company.id)
|
||||
.catch(() => undefined);
|
||||
const data = await this.companiesService.fetchETradeData(
|
||||
dto.tin,
|
||||
companyId,
|
||||
);
|
||||
return new ETradeResponseDto(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,8 +75,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
async existsByTin(tin: string): Promise<boolean> {
|
||||
const count = await this.repository.count({ where: { tin } as any });
|
||||
async existsByTin(tin: string, excludeCompanyId?: string): Promise<boolean> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
.where('company.tin = :tin', { tin });
|
||||
if (excludeCompanyId) {
|
||||
qb.andWhere('company.id != :excludeCompanyId', { excludeCompanyId });
|
||||
}
|
||||
const count = await qb.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CompaniesService } from "./companies.service";
|
||||
import { CompanyType } from "./entities/company.entity";
|
||||
import { ProfileStatus, ProfileType } from "./entities/company-profile.entity";
|
||||
|
||||
/**
|
||||
* EDRFREIGHT-416: onboarding asked for a deselected role's documents.
|
||||
*
|
||||
* Re-running role selection used to only ADD operational profiles, so a role
|
||||
* the user unticked on the way back left its company_profile row behind — and
|
||||
* every role-driven requirement (business license, forwarder PoA) is derived
|
||||
* from those rows. startOnboarding now reconciles both directions.
|
||||
*/
|
||||
|
||||
interface ExistingProfile {
|
||||
id: string;
|
||||
type: ProfileType;
|
||||
status: ProfileStatus;
|
||||
}
|
||||
|
||||
function makeService(existing: ExistingProfile[]) {
|
||||
const companyProfilesRepo = {
|
||||
findByCompanyId: jest.fn(async () => existing),
|
||||
create: jest.fn(async (row: Record<string, unknown>) => ({
|
||||
id: "new",
|
||||
...row,
|
||||
})),
|
||||
softDelete: jest.fn(async () => undefined),
|
||||
};
|
||||
const companiesRepo = { update: jest.fn(async () => null) };
|
||||
const profilesRepo = {
|
||||
findByUserId: jest.fn(async () => ({
|
||||
id: "external-1",
|
||||
companyId: "company-1",
|
||||
company: { id: "company-1" },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new CompaniesService(
|
||||
companiesRepo as never,
|
||||
companyProfilesRepo as never,
|
||||
{} as never,
|
||||
profilesRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
jest
|
||||
.spyOn(service, "getCompanyInfoByUserId")
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
({ profile: { id: "external-1" }, company: { id: "company-1" } }) as never,
|
||||
);
|
||||
|
||||
return { service, companyProfilesRepo };
|
||||
}
|
||||
|
||||
const identity = { userId: "user-1", firstName: "Abebe", lastName: "K" };
|
||||
|
||||
const start = (service: CompaniesService, roles: ProfileType[]) =>
|
||||
service.startOnboarding(identity as never, CompanyType.Customer, roles);
|
||||
|
||||
describe("re-running role selection reconciles the operational profiles", () => {
|
||||
it("drops the profile for a role the user deselected", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-ff",
|
||||
type: ProfileType.freightForwarder,
|
||||
status: ProfileStatus.Pending,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledWith("p-ff");
|
||||
expect(companyProfilesRepo.softDelete).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps an already-approved profile even when it is unticked", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
{
|
||||
id: "p-exp",
|
||||
type: ProfileType.exporter,
|
||||
status: ProfileStatus.Active,
|
||||
},
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adds a newly-picked role", async () => {
|
||||
const { service, companyProfilesRepo } = makeService([
|
||||
{ id: "p-imp", type: ProfileType.importer, status: ProfileStatus.Pending },
|
||||
]);
|
||||
|
||||
await start(service, [ProfileType.importer, ProfileType.exporter]);
|
||||
|
||||
expect(companyProfilesRepo.softDelete).not.toHaveBeenCalled();
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledTimes(1);
|
||||
expect(companyProfilesRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ProfileType.exporter }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import { ETradeService } from "./services/etrade.service";
|
||||
import { CompanyNotifierService } from "./company-notifier.service";
|
||||
import { OnboardingRequirementsResponseDto } from "./dto/onboarding-requirements-response.dto";
|
||||
import { normalizeE164 } from "../../common/validators/is-phone-number.validator";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { CreateCompanyDto } from "./dto/create-company.dto";
|
||||
import { UpdateCompanyDto } from "./dto/update-company.dto";
|
||||
import { CreateExternalProfileDto } from "./dto/create-external-profile.dto";
|
||||
@@ -116,6 +117,28 @@ const IDENTITY_OWNED_FIELDS: Record<IdentitySubject, string[]> = {
|
||||
poa: ["poaName", "poaEmail", "poaPhone", "poaAddress"],
|
||||
};
|
||||
|
||||
/**
|
||||
* `UpdateProfileDto` fields eTrade is the sole source of truth for. A request
|
||||
* touching any of these must be re-checked against a fresh eTrade lookup —
|
||||
* see `assertEtradeFieldsAuthentic`.
|
||||
*/
|
||||
const ETRADE_SOURCED_FIELDS = [
|
||||
"companyName",
|
||||
"tin",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
"etradePhone",
|
||||
] as const satisfies readonly (keyof UpdateProfileDto)[];
|
||||
|
||||
/** The attributes a verification writes, for one person. */
|
||||
interface VerifiedIdentityAttributes {
|
||||
[key: string]: unknown;
|
||||
@@ -298,8 +321,9 @@ export class CompaniesService {
|
||||
* chosen operational role(s) up front, so every subsequent wizard step can
|
||||
* save incrementally (PATCH /profile, /onboarding-step) against existing rows.
|
||||
*
|
||||
* Idempotent: if the user already has a profile, returns it unchanged (only
|
||||
* adding any newly-chosen roles). The draft company carries a placeholder TIN
|
||||
* Idempotent: if the user already has a profile, returns it unchanged, with
|
||||
* the operational profiles reconciled against the roles just chosen (added
|
||||
* and — for still-pending ones — removed). The draft company carries a placeholder TIN
|
||||
* (the real one is filled on the Company Information step) and stays
|
||||
* status=pending / onboardingCompleted=false until the wizard finishes.
|
||||
*/
|
||||
@@ -314,7 +338,7 @@ export class CompaniesService {
|
||||
const existing = await this.profilesRepo.findByUserId(identity.userId);
|
||||
if (existing) {
|
||||
const companyId = existing.company?.id ?? existing.companyId;
|
||||
await this.ensureCompanyProfiles(companyId, companyType, roles);
|
||||
await this.syncCompanyProfiles(companyId, companyType, roles);
|
||||
if (nationality) {
|
||||
await this.companiesRepo.update(companyId, { nationality });
|
||||
}
|
||||
@@ -345,25 +369,44 @@ export class CompaniesService {
|
||||
onboardingCompleted: false,
|
||||
});
|
||||
|
||||
await this.ensureCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
await this.syncCompanyProfiles(company.id, companyType, chosenTypes);
|
||||
|
||||
return this.getCompanyInfoByUserId(identity.userId);
|
||||
}
|
||||
|
||||
/** Create any of the requested operational profiles that don't exist yet. */
|
||||
private async ensureCompanyProfiles(
|
||||
/**
|
||||
* Reconcile the company's operational profiles with the roles the user has
|
||||
* selected: create the missing ones, drop the ones they deselected.
|
||||
*
|
||||
* Dropping matters because every role-driven onboarding requirement — the
|
||||
* per-profile business license, the freight-forwarder PoA rule, the license
|
||||
* cards in the wizard — is derived from these rows. A row left behind after
|
||||
* the user went back and unticked a role keeps asking for that role's
|
||||
* documents (EDRFREIGHT-416). Only still-pending profiles are removed: an
|
||||
* approved one is live (it can carry bookings and contracts) and re-running
|
||||
* role selection must never delete it.
|
||||
*/
|
||||
private async syncCompanyProfiles(
|
||||
companyId: string,
|
||||
companyType: CompanyType,
|
||||
roles: ProfileType[],
|
||||
): Promise<void> {
|
||||
const allowedTypes = this.getProfileTypeForCompanyType(companyType);
|
||||
for (const type of roles) {
|
||||
if (!allowedTypes.includes(type)) continue;
|
||||
const existing = await this.companyProfilesRepo.findByType(
|
||||
companyId,
|
||||
type,
|
||||
);
|
||||
if (existing) continue;
|
||||
const chosen = roles.filter((t) => allowedTypes.includes(t));
|
||||
const existing = await this.companyProfilesRepo.findByCompanyId(companyId);
|
||||
|
||||
for (const profile of existing) {
|
||||
if (chosen.includes(profile.type)) continue;
|
||||
if (profile.status !== ProfileStatus.Pending) continue;
|
||||
// The license files uploaded against this profile go with it: they are
|
||||
// only ever read per company_profile id, so a soft-deleted profile
|
||||
// leaves nothing behind to prompt for. Re-picking the role creates a
|
||||
// fresh profile the user uploads against again.
|
||||
await this.companyProfilesRepo.softDelete(profile.id);
|
||||
}
|
||||
|
||||
for (const type of chosen) {
|
||||
if (existing.some((p) => p.type === type)) continue;
|
||||
// No reference yet — minted on backoffice approval (setCompanyProfileStatus).
|
||||
await this.companyProfilesRepo.create({
|
||||
companyId,
|
||||
@@ -720,6 +763,31 @@ export class CompaniesService {
|
||||
Object.assign(attrUpdates, dto.faydaIdentity);
|
||||
}
|
||||
|
||||
// companyEmail/companyPhone are the Company-column mirrors of the owner's
|
||||
// verified contact details (the portal derives and submits them, it never
|
||||
// lets the customer type them once verified) — lock them the same way
|
||||
// ownerEmail/ownerPhone themselves are locked below, once there is a
|
||||
// verified owner to lock them to.
|
||||
if (attrUpdates.ownerFaydaSub) {
|
||||
if (
|
||||
dto.companyEmail !== undefined &&
|
||||
dto.companyEmail !== attrUpdates.ownerEmail
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"companyEmail is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
dto.companyPhone !== undefined &&
|
||||
normalizeE164(dto.companyPhone) !==
|
||||
normalizeE164(String(attrUpdates.ownerPhone ?? ""))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
"companyPhone is set by the owner's Fayda verification and cannot be edited. Re-verify to change it.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Renaming a Fayda-verified person by hand would launder the guarantee
|
||||
// away, so the fields the verification owns are refused once it exists.
|
||||
for (const subject of ["owner", "poa"] as IdentitySubject[]) {
|
||||
@@ -787,6 +855,8 @@ export class CompaniesService {
|
||||
): Promise<ProfileResponseDto> {
|
||||
const { profile, company } = await this.getCompanyInfoByUserId(userId);
|
||||
|
||||
await this.assertEtradeFieldsAuthentic(company, dto);
|
||||
|
||||
// Naming (or renaming) a Power of Attorney is one of the writes that can
|
||||
// leave the company with a representative and nothing evidencing them, so
|
||||
// it is gated here. Edits that don't touch the PoA are left alone — a
|
||||
@@ -2853,7 +2923,10 @@ export class CompaniesService {
|
||||
return match?.id ?? null;
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string) {
|
||||
/** Resolve a TIN's live eTrade registration data. Throws when eTrade has no matching business licence. */
|
||||
private async resolveEtradeRegistration(
|
||||
tin: string,
|
||||
): Promise<CompanyRegistrationData> {
|
||||
const { businessInfo, companyInfo } =
|
||||
await this.etradeService.resolveCompanyData(tin);
|
||||
if (!businessInfo) {
|
||||
@@ -2861,11 +2934,71 @@ export class CompaniesService {
|
||||
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
|
||||
);
|
||||
}
|
||||
const registrationData = this.etradeService.extractRegistrationData(
|
||||
businessInfo,
|
||||
companyInfo,
|
||||
return this.etradeService.extractRegistrationData(businessInfo, companyInfo);
|
||||
}
|
||||
|
||||
async fetchETradeData(tin: string, excludeCompanyId?: string) {
|
||||
const registrationData = await this.resolveEtradeRegistration(tin);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(
|
||||
tin,
|
||||
excludeCompanyId,
|
||||
);
|
||||
const tinTaken = await this.companiesRepo.existsByTin(tin);
|
||||
return { ...registrationData, tinTaken };
|
||||
}
|
||||
|
||||
/**
|
||||
* An eTrade-sourced field can only ever hold what a fresh eTrade lookup for
|
||||
* this TIN actually returns — the portal never lets the customer type these
|
||||
* once eTrade has supplied them, so a mismatch here means either stale
|
||||
* client state or a hand-crafted request, and either way the write is
|
||||
* refused rather than silently trusting it.
|
||||
*/
|
||||
private async assertEtradeFieldsAuthentic(
|
||||
company: Company,
|
||||
dto: UpdateProfileDto,
|
||||
): Promise<void> {
|
||||
const touched = ETRADE_SOURCED_FIELDS.some(
|
||||
(key) => dto[key] !== undefined,
|
||||
);
|
||||
if (!touched) return;
|
||||
|
||||
const tin = dto.tin ?? company.tin;
|
||||
const registration = await this.resolveEtradeRegistration(tin);
|
||||
const expected: Partial<Record<(typeof ETRADE_SOURCED_FIELDS)[number], string>> = {
|
||||
companyName: registration.companyName,
|
||||
licenceNumber: registration.licenceNumber,
|
||||
statusDescription: registration.statusDescription,
|
||||
dateRegistered: registration.dateRegistered,
|
||||
renewedFrom: registration.renewedFrom,
|
||||
renewalDate: registration.renewalDate,
|
||||
renewedTo: registration.renewedTo,
|
||||
region: registration.region,
|
||||
zone: registration.zone,
|
||||
woreda: registration.woreda,
|
||||
kebele: registration.kebele,
|
||||
houseNo: registration.houseNo,
|
||||
etradePhone:
|
||||
registration.managerPhone ||
|
||||
registration.regularPhone ||
|
||||
registration.mobilePhone,
|
||||
};
|
||||
|
||||
for (const key of ETRADE_SOURCED_FIELDS) {
|
||||
const submitted = dto[key];
|
||||
if (submitted === undefined) continue;
|
||||
const source = expected[key];
|
||||
// eTrade left this field blank — the onboarding/settings card falls back
|
||||
// to letting the customer type it directly, so nothing to check against.
|
||||
if (!source) continue;
|
||||
const same =
|
||||
key === "etradePhone"
|
||||
? normalizeE164(String(submitted)) === normalizeE164(source)
|
||||
: submitted === source;
|
||||
if (!same) {
|
||||
throw new BadRequestException(
|
||||
`${key} doesn't match eTrade's current record for this TIN. Re-verify with eTrade to pick up the latest details.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
woreda: "Woreda",
|
||||
kebele: "Kebele",
|
||||
houseNo: "House no.",
|
||||
statusDescription: "eTrade status",
|
||||
dateRegistered: "Date registered",
|
||||
renewedFrom: "Renewed from",
|
||||
renewalDate: "Renewal date",
|
||||
renewedTo: "Renewed to",
|
||||
etradePhone: "eTrade phone",
|
||||
ownerPassportNumber: "Owner passport number",
|
||||
};
|
||||
|
||||
/** Best-effort current value on the live company for a proposed field key. */
|
||||
@@ -85,6 +92,74 @@ function currentValue(company: Company, key: string): string {
|
||||
return v === null || v === undefined || v === "" ? "—" : String(v);
|
||||
}
|
||||
|
||||
/** Subject a staged `snapshot.faydaIdentity` blob belongs to, from which of its `*FaydaSub` keys is present. */
|
||||
function faydaIdentitySubject(
|
||||
snapshot: Record<string, unknown>,
|
||||
): "owner" | "poa" | null {
|
||||
if ("ownerFaydaSub" in snapshot) return "owner";
|
||||
if ("poaFaydaSub" in snapshot) return "poa";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `stageIdentityChange` writes a nested `snapshot.faydaIdentity` object
|
||||
* (attrs-key names like `ownerEmail`, not top-level DTO keys), so the generic
|
||||
* `DiffRow` loop below can't render it — it would just stringify to
|
||||
* `[object Object]`. Render it as its own before/after block instead, using
|
||||
* the company's current `identity.owner`/`identity.poa` as the "before" side.
|
||||
*/
|
||||
function FaydaIdentityDiff({
|
||||
company,
|
||||
snapshot,
|
||||
}: {
|
||||
company: Company;
|
||||
snapshot: Record<string, unknown>;
|
||||
}) {
|
||||
const subject = faydaIdentitySubject(snapshot);
|
||||
if (!subject) return null;
|
||||
const current =
|
||||
subject === "owner" ? company.identity?.owner : company.identity?.poa;
|
||||
const read = (key: string) => snapshot[`${subject}${key}`] as string | undefined;
|
||||
const verifiedAt = read("FaydaVerifiedAt");
|
||||
const fields: { label: string; from?: string | null; to?: string }[] = [
|
||||
{ label: "Name", from: current?.name, to: read("Name") },
|
||||
{ label: "Email", from: current?.email, to: read("Email") },
|
||||
{ label: "Phone", from: current?.phone, to: read("Phone") },
|
||||
{ label: "Address", from: current?.address, to: read("Address") },
|
||||
].filter((f) => f.to !== undefined);
|
||||
|
||||
return (
|
||||
<Stack gap={8}>
|
||||
<Group gap={8}>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{subject === "owner" ? "Owner re-verification" : "PoA re-verification"}
|
||||
</Text>
|
||||
{verifiedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Verified {formatDate(verifiedAt)}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{fields.length > 0 ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||
{fields.map((f) => (
|
||||
<DiffRow
|
||||
key={f.label}
|
||||
label={f.label}
|
||||
from={f.from?.trim() ? f.from : "—"}
|
||||
to={f.to?.trim() ? f.to : "—"}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
Identity re-verified — no name/email/phone/address change.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffRow({
|
||||
label,
|
||||
from,
|
||||
@@ -153,8 +228,11 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
if (!pending && history.length === 0) return null;
|
||||
|
||||
const proposedKeys = pending
|
||||
? Object.keys(pending.snapshot ?? {})
|
||||
? Object.keys(pending.snapshot ?? {}).filter((k) => k !== "faydaIdentity")
|
||||
: ([] as string[]);
|
||||
const faydaIdentitySnapshot = pending?.snapshot?.faydaIdentity as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const docCount = pending?.documentFileIds?.length ?? 0;
|
||||
const licenseChanges = pending?.licenseChanges ?? [];
|
||||
const documentChanges = pending?.documentChanges ?? [];
|
||||
@@ -209,10 +287,14 @@ export function ChangeRequestReview({ company }: { company: Company }) {
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
) : (
|
||||
) : !faydaIdentitySnapshot ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No field changes — document uploads only.
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{faydaIdentitySnapshot && (
|
||||
<FaydaIdentityDiff company={company} snapshot={faydaIdentitySnapshot} />
|
||||
)}
|
||||
|
||||
{documentChanges.length > 0 && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { BadgeCheck, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { BadgeCheck, Clock, ShieldCheck, XCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
verifaydaService,
|
||||
@@ -33,6 +33,13 @@ interface FaydaVerifyPanelProps {
|
||||
/** Called with the fresh company-wide state once a verification lands. */
|
||||
onVerified: (next: CompanyIdentityState) => void;
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* True when a fresh verification for this person is already staged in a
|
||||
* pending change request. On an active company a re-verification never
|
||||
* touches the live record — it's staged for review — so `state` alone
|
||||
* would keep showing the OLD verified data with no sign anything happened.
|
||||
*/
|
||||
pendingReview?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
@@ -57,6 +64,7 @@ export default function FaydaVerifyPanel({
|
||||
required,
|
||||
onVerified,
|
||||
disabled,
|
||||
pendingReview,
|
||||
}: FaydaVerifyPanelProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -151,6 +159,16 @@ export default function FaydaVerifyPanel({
|
||||
</Badge>
|
||||
)
|
||||
)}
|
||||
{pendingReview && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="amber"
|
||||
leftSection={<Clock size={11} />}
|
||||
>
|
||||
Re-verification pending review
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -225,8 +225,12 @@ export default function OnboardingWizardDialog({
|
||||
const finishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
// Per-role business licenses (file model, resource=company_profiles).
|
||||
// Keys for roles the user deselected on a trip back to role selection are
|
||||
// dropped — that profile no longer exists, so uploading against it would
|
||||
// 404 (and the license isn't wanted any more anyway).
|
||||
const liveProfileIds = new Set(existingProfiles.map((p) => p.id));
|
||||
for (const [profileId, files] of Object.entries(licenseFiles)) {
|
||||
if (files.length > 0) {
|
||||
if (files.length > 0 && liveProfileIds.has(profileId)) {
|
||||
await companiesService.uploadProfileLicense(profileId, files);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,7 @@ export default function SettingsPage() {
|
||||
enabled so the customer can still review what they submitted. */}
|
||||
<Tabs.Panel value="company">
|
||||
<Fieldset disabled={locked} variant="unstyled" p={0}>
|
||||
<TabCompanyProfile mode="edit" profile={profile} />
|
||||
<TabCompanyProfile mode="edit" profile={profile} user={user ?? undefined} />
|
||||
</Fieldset>
|
||||
<OperationalServicesCard profile={profile} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { isValidPhone, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import type {
|
||||
CompanyProfileInput,
|
||||
CreateCompanyPayload,
|
||||
} from "@/services/companies.service";
|
||||
import type { AuthUser } from "@/types/auth";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -18,11 +21,15 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Building2, CheckCircle2, Save, XCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ETHIOPIAN_REGIONS, type CompanyRegistrationData } from "@edr/types";
|
||||
import OnboardingRoleSelect from "./OnboardingRoleSelect";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import ETradeInfo, { type ETradeStatus } from "@/components/onboarding/ETradeInfo";
|
||||
import { ReadOnlyField } from "@/pages/accounts/companyProfileForm/ReadOnlyField";
|
||||
import StepSection from "@/pages/accounts/companyProfileForm/StepSection";
|
||||
|
||||
export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
@@ -32,7 +39,9 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
.min(1, "Company phone is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
companyLocation: z.string().min(1, "Location is required"),
|
||||
companyAddress: z.string().min(1, "Address is required"),
|
||||
// Derived from the eTrade address parts (region/zone/woreda/kebele/houseNo);
|
||||
// no standalone input.
|
||||
companyAddress: z.string().optional(),
|
||||
tinNumber: z.string().regex(/^\d{10}$/, "TIN must be exactly 10 digits"),
|
||||
vatNumber: z
|
||||
.string()
|
||||
@@ -41,24 +50,61 @@ export const COMPANY_PROFILE_SCHEMA = z.object({
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
ownerPassportNumber: z.string().optional(),
|
||||
// Registration/address fields are eTrade-sourced — locked once eTrade
|
||||
// supplies a value, editable only as an escape hatch when it doesn't
|
||||
// (see LockedField below). Not typed by hand in the normal case.
|
||||
licenceNumber: z.string().optional(),
|
||||
statusDescription: z.string().optional(),
|
||||
dateRegistered: z.string().optional(),
|
||||
renewedFrom: z.string().optional(),
|
||||
renewalDate: z.string().optional(),
|
||||
renewedTo: z.string().optional(),
|
||||
region: z
|
||||
.string()
|
||||
.refine((v) => (ETHIOPIAN_REGIONS as readonly string[]).includes(v), {
|
||||
message: "Region is required",
|
||||
}),
|
||||
zone: z.string().min(1, "Zone is required"),
|
||||
woreda: z.string().min(1, "Woreda is required"),
|
||||
kebele: z.string().min(1, "Kebele is required"),
|
||||
houseNo: z.string().min(1, "House number is required"),
|
||||
});
|
||||
|
||||
export type CompanyProfileFormData = z.infer<typeof COMPANY_PROFILE_SCHEMA>;
|
||||
|
||||
/** UpdateProfilePayload keys eTrade owns — only resent when the customer re-verified them this session. */
|
||||
const ETRADE_BUNDLE_FIELDS = [
|
||||
"companyName",
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewedFrom",
|
||||
"renewalDate",
|
||||
"renewedTo",
|
||||
"region",
|
||||
"zone",
|
||||
"woreda",
|
||||
"kebele",
|
||||
"houseNo",
|
||||
] as const satisfies readonly (keyof CompanyProfileFormData)[];
|
||||
|
||||
interface TabCompanyProfileProps {
|
||||
profile?: ProfileResponse;
|
||||
mode?: "edit" | "create";
|
||||
onCreateSuccess?: () => void;
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export default function TabCompanyProfile({
|
||||
profile,
|
||||
mode = "edit",
|
||||
onCreateSuccess,
|
||||
user,
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [tinStatus, setTinStatus] = useState<ETradeStatus>("idle");
|
||||
|
||||
const defaultValues = useMemo((): CompanyProfileFormData => {
|
||||
if (profile) {
|
||||
@@ -71,6 +117,17 @@ export default function TabCompanyProfile({
|
||||
tinNumber: profile.tinNumber,
|
||||
vatNumber: profile.vatNumber ?? "",
|
||||
ownerPassportNumber: profile.identity?.owner.passportNumber ?? "",
|
||||
licenceNumber: profile.licenceNumber ?? "",
|
||||
statusDescription: profile.statusDescription ?? "",
|
||||
dateRegistered: profile.dateRegistered ?? "",
|
||||
renewedFrom: profile.renewedFrom ?? "",
|
||||
renewalDate: profile.renewalDate ?? "",
|
||||
renewedTo: profile.renewedTo ?? "",
|
||||
region: profile.region ?? "",
|
||||
zone: profile.zone ?? "",
|
||||
woreda: profile.woreda ?? "",
|
||||
kebele: profile.kebele ?? "",
|
||||
houseNo: profile.houseNo ?? "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -82,6 +139,17 @@ export default function TabCompanyProfile({
|
||||
tinNumber: "",
|
||||
vatNumber: "",
|
||||
ownerPassportNumber: "",
|
||||
licenceNumber: "",
|
||||
statusDescription: "",
|
||||
dateRegistered: "",
|
||||
renewedFrom: "",
|
||||
renewalDate: "",
|
||||
renewedTo: "",
|
||||
region: "",
|
||||
zone: "",
|
||||
woreda: "",
|
||||
kebele: "",
|
||||
houseNo: "",
|
||||
};
|
||||
}, [profile]);
|
||||
|
||||
@@ -90,22 +158,109 @@ export default function TabCompanyProfile({
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty },
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<CompanyProfileFormData>({
|
||||
resolver: zodResolver(COMPANY_PROFILE_SCHEMA),
|
||||
values: defaultValues,
|
||||
});
|
||||
|
||||
const identity = profile?.identity;
|
||||
const verifiedIdentity = identity?.faydaRequired === true;
|
||||
|
||||
// companyEmail/companyPhone are the owner's verified contact details, never
|
||||
// typed — same derivation as the onboarding wizard, just fed from the saved
|
||||
// profile instead of an in-progress form.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setValue("companyEmail", identity?.owner.email ?? user.email ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.email, user?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setValue(
|
||||
"companyPhone",
|
||||
identity?.owner.phone ??
|
||||
profile?.etradePhone ??
|
||||
toEthiopianE164(user.phoneNumber) ??
|
||||
"",
|
||||
{ shouldValidate: true },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [identity?.owner.phone, profile?.etradePhone, user?.phoneNumber]);
|
||||
|
||||
// companyAddress is composed from the (locked) eTrade address parts, not
|
||||
// typed directly.
|
||||
const region = watch("region");
|
||||
const zone = watch("zone");
|
||||
const woreda = watch("woreda");
|
||||
const kebele = watch("kebele");
|
||||
const houseNo = watch("houseNo");
|
||||
useEffect(() => {
|
||||
const composed = [houseNo, kebele, woreda, zone, region]
|
||||
.filter((part) => part && part.trim())
|
||||
.join(", ");
|
||||
setValue("companyAddress", composed);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [region, zone, woreda, kebele, houseNo]);
|
||||
|
||||
const handleETradeDataLoaded = (data: CompanyRegistrationData) => {
|
||||
if (data.companyName) {
|
||||
setValue("companyName", data.companyName, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
setValue("licenceNumber", data.licenceNumber, { shouldDirty: true });
|
||||
setValue("statusDescription", data.statusDescription, { shouldDirty: true });
|
||||
setValue("dateRegistered", data.dateRegistered, { shouldDirty: true });
|
||||
setValue("renewedFrom", data.renewedFrom, { shouldDirty: true });
|
||||
setValue("renewalDate", data.renewalDate, { shouldDirty: true });
|
||||
setValue("renewedTo", data.renewedTo, { shouldDirty: true });
|
||||
setValue("region", data.region, { shouldDirty: true });
|
||||
setValue("zone", data.zone, { shouldDirty: true });
|
||||
setValue("woreda", data.woreda, { shouldDirty: true });
|
||||
setValue("kebele", data.kebele, { shouldDirty: true });
|
||||
setValue("houseNo", data.houseNo, { shouldDirty: true });
|
||||
};
|
||||
|
||||
// A previously-verified TIN (every active company has one) counts as
|
||||
// verified without a refetch — the registration fields being populated at
|
||||
// all is proof it passed before.
|
||||
const registration = watch([
|
||||
"licenceNumber",
|
||||
"statusDescription",
|
||||
"dateRegistered",
|
||||
"renewalDate",
|
||||
"renewedFrom",
|
||||
"renewedTo",
|
||||
]);
|
||||
const hasRegistrationDetails = registration.some((v) => v && v.trim());
|
||||
const tinVerified = tinStatus === "verified" || hasRegistrationDetails;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (data: CompanyProfileFormData) => {
|
||||
// eTrade-owned fields are only resent when they actually changed this
|
||||
// session (a real re-verify) — resubmitting the unchanged live values
|
||||
// on every save would otherwise trigger the server's eTrade
|
||||
// authenticity re-check for no reason.
|
||||
const etradeBundle: Record<string, string | undefined> = {};
|
||||
for (const key of ETRADE_BUNDLE_FIELDS) {
|
||||
if (dirtyFields[key]) etradeBundle[key] = data[key];
|
||||
}
|
||||
if (dirtyFields.tinNumber) etradeBundle.tin = data.tinNumber;
|
||||
|
||||
const base = {
|
||||
companyName: data.companyName,
|
||||
companyEmail: data.companyEmail,
|
||||
companyPhone: data.companyPhone,
|
||||
companyLocation: data.companyLocation,
|
||||
companyAddress: data.companyAddress,
|
||||
tin: data.tinNumber,
|
||||
vatNumber: data.vatNumber ?? "",
|
||||
...etradeBundle,
|
||||
...(data.ownerPassportNumber !== undefined
|
||||
? { ownerPassportNumber: data.ownerPassportNumber }
|
||||
: {}),
|
||||
@@ -119,6 +274,8 @@ export default function TabCompanyProfile({
|
||||
: "customer";
|
||||
const payload: CreateCompanyPayload = {
|
||||
...base,
|
||||
companyName: data.companyName,
|
||||
tin: data.tinNumber,
|
||||
companyType,
|
||||
companyProfiles: selectedRoles.map((type) => ({
|
||||
type: type as CompanyProfileInput["type"],
|
||||
@@ -143,6 +300,15 @@ export default function TabCompanyProfile({
|
||||
mutation.mutate(data);
|
||||
};
|
||||
|
||||
const saveErrorMessage = mutation.isError
|
||||
? extractApiError(mutation.error).message
|
||||
: null;
|
||||
|
||||
const pendingOwnerReview = Boolean(
|
||||
(profile?.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||
?.faydaIdentity?.ownerFaydaSub,
|
||||
);
|
||||
|
||||
// During onboarding the role selection gates the form: nothing else shows
|
||||
// until the user picks Importer/Exporter or Freight Forwarder.
|
||||
const showForm = !isCreate || selectedRoles.length > 0;
|
||||
@@ -164,92 +330,62 @@ export default function TabCompanyProfile({
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
{isCreate
|
||||
? "Enter your company registration details to get started"
|
||||
: "Edit your company registration details"}
|
||||
: "Your registration and identity come from eTrade and Fayda — re-verify to refresh them."}
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
error={errors.companyName?.message}
|
||||
{...register("companyName")}
|
||||
/>
|
||||
<Stack gap="xl">
|
||||
<StepSection
|
||||
index={1}
|
||||
title="VAT number"
|
||||
status={watch("vatNumber") ? "done" : "todo"}
|
||||
>
|
||||
<TextInput
|
||||
label="VAT Number (optional)"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={20}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</StepSection>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Company Email"
|
||||
type="email"
|
||||
placeholder="ops@company.com"
|
||||
error={errors.companyEmail?.message}
|
||||
{...register("companyEmail")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="companyPhone"
|
||||
label="Company Phone"
|
||||
required
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="Address"
|
||||
placeholder="Bole Subcity, Woreda 03"
|
||||
error={errors.companyAddress?.message}
|
||||
{...register("companyAddress")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="TIN Number (10 digits)"
|
||||
placeholder="1234567890"
|
||||
maxLength={10}
|
||||
error={errors.tinNumber?.message}
|
||||
{...register("tinNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<TextInput
|
||||
label="VAT Number (optional)"
|
||||
placeholder="e.g. 0012345678"
|
||||
maxLength={20}
|
||||
error={errors.vatNumber?.message}
|
||||
{...register("vatNumber")}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{profile?.identity && (
|
||||
<>
|
||||
{identity && (
|
||||
<StepSection
|
||||
index={2}
|
||||
title="Owner identity"
|
||||
subtitle={
|
||||
verifiedIdentity
|
||||
? "Re-verify the company owner with Fayda — their name, phone, email and address are refreshed from the verification."
|
||||
: "The company owner's passport number."
|
||||
}
|
||||
status={
|
||||
verifiedIdentity
|
||||
? identity.owner.verified
|
||||
? "done"
|
||||
: identity.faydaRequired
|
||||
? "blocked"
|
||||
: "todo"
|
||||
: (watch("ownerPassportNumber")?.trim()?.length ?? 0) > 0
|
||||
? "done"
|
||||
: identity.passportRequired
|
||||
? "blocked"
|
||||
: "todo"
|
||||
}
|
||||
>
|
||||
<FaydaVerifyPanel
|
||||
subject="owner"
|
||||
title="Company owner"
|
||||
state={profile.identity.owner}
|
||||
required={profile.identity.faydaRequired}
|
||||
state={identity.owner}
|
||||
required={identity.faydaRequired}
|
||||
disabled={mutation.isPending}
|
||||
pendingReview={pendingOwnerReview}
|
||||
onVerified={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{profile.identity.passportRequired && (
|
||||
{identity.passportRequired && (
|
||||
<TextInput
|
||||
label="Owner Passport Number"
|
||||
placeholder="P1234567"
|
||||
@@ -258,9 +394,45 @@ export default function TabCompanyProfile({
|
||||
{...register("ownerPassportNumber")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<ReadOnlyField label="Company email" value={watch("companyEmail")} />
|
||||
<ReadOnlyField label="Company phone" value={watch("companyPhone")} />
|
||||
</SimpleGrid>
|
||||
</StepSection>
|
||||
)}
|
||||
|
||||
<StepSection
|
||||
index={3}
|
||||
title="Company TIN"
|
||||
subtitle="Re-verify with eTrade to refresh your registration record — nothing here is typed by hand."
|
||||
status={
|
||||
tinVerified ? "done" : tinStatus === "taken" ? "blocked" : "todo"
|
||||
}
|
||||
>
|
||||
<ETradeInfo
|
||||
tin={watch("tinNumber")}
|
||||
register={register("tinNumber")}
|
||||
error={errors.tinNumber?.message}
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
onStatusChange={setTinStatus}
|
||||
/>
|
||||
{tinVerified && (
|
||||
<EtradeLockedCard
|
||||
tin={watch("tinNumber")}
|
||||
register={register}
|
||||
watch={watch}
|
||||
errors={errors}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</StepSection>
|
||||
|
||||
<TextInput
|
||||
label="Location"
|
||||
placeholder="Addis Ababa, Ethiopia"
|
||||
error={errors.companyLocation?.message}
|
||||
{...register("companyLocation")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Group
|
||||
@@ -278,11 +450,11 @@ export default function TabCompanyProfile({
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{mutation.isError && (
|
||||
{saveErrorMessage && (
|
||||
<Group gap={6} c="red">
|
||||
<XCircle size={16} />
|
||||
<Text size="sm" fw={500}>
|
||||
{isCreate ? "Failed to create profile" : "Save failed"}
|
||||
{saveErrorMessage}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
@@ -313,3 +485,111 @@ export default function TabCompanyProfile({
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The verified eTrade record, locked read-only — same escape hatch as
|
||||
* onboarding's ETradeCompanyCard: a field eTrade left blank falls back to an
|
||||
* editable input rather than trapping the customer.
|
||||
*/
|
||||
function EtradeLockedCard({
|
||||
tin,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
control,
|
||||
}: {
|
||||
tin: string;
|
||||
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
|
||||
}) {
|
||||
const companyName = watch("companyName");
|
||||
const region = watch("region");
|
||||
|
||||
return (
|
||||
<Card padding="md" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<Text fw={600} c="edr-text">
|
||||
{companyName?.trim() ? companyName : "Company record"}
|
||||
</Text>
|
||||
<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?.trim() ? (
|
||||
<ReadOnlyField label="Region" value={region} />
|
||||
) : (
|
||||
<RegionSelect control={control} error={errors.region?.message} />
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedField({
|
||||
label,
|
||||
name,
|
||||
register,
|
||||
watch,
|
||||
errors,
|
||||
}: {
|
||||
label: string;
|
||||
name: keyof CompanyProfileFormData;
|
||||
register: ReturnType<typeof useForm<CompanyProfileFormData>>["register"];
|
||||
watch: ReturnType<typeof useForm<CompanyProfileFormData>>["watch"];
|
||||
errors: ReturnType<typeof useForm<CompanyProfileFormData>>["formState"]["errors"];
|
||||
}) {
|
||||
const value = watch(name) as string | undefined;
|
||||
if (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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionSelect({
|
||||
control,
|
||||
error,
|
||||
}: {
|
||||
control: ReturnType<typeof useForm<CompanyProfileFormData>>["control"];
|
||||
error?: string;
|
||||
}) {
|
||||
return (
|
||||
<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={error}
|
||||
value={field.value || null}
|
||||
onChange={(v) => field.onChange(v ?? "")}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -283,6 +283,10 @@ export default function TabPowerOfAttorney({
|
||||
state={identity.poa}
|
||||
required={identity.faydaRequired}
|
||||
disabled={mutation.isPending}
|
||||
pendingReview={Boolean(
|
||||
(profile.pendingChanges as { faydaIdentity?: Record<string, unknown> } | null)
|
||||
?.faydaIdentity?.poaFaydaSub,
|
||||
)}
|
||||
onVerified={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
|
||||
Reference in New Issue
Block a user