refactor(freight-backoffice): review one identity and the eTrade owner match

- Replace the general-manager rows with owner rows across the customer
  detail, contract cards, types and service mappings.
- The identity card names its subject ("Verifies for this company") and shows
  whether the asserted owner matches the manager on the eTrade licence:
  amber when it does not, green when it does, dimmed when eTrade named
  nobody. Advisory only, since the comparison is a fuzzy transliteration
  match.
- A missing delegation letter now keys on poaDeclared === "yes".
- Keep the three general-manager labels in the change-request label map so
  historical requests still render readable field names.
This commit is contained in:
Nathnael
2026-08-11 11:54:05 +00:00
parent 293f255daa
commit 02d9debe1d
6 changed files with 92 additions and 43 deletions

View File

@@ -170,12 +170,13 @@ export function ContractCustomerCard({
/> />
</SectionCard> </SectionCard>
<SectionCard icon={User} title="General manager" accent="grape"> {/* Whoever the eTrade licence names as the business's manager. */}
<SectionCard icon={User} title="Owner" accent="grape">
<InfoRows <InfoRows
rows={[ rows={[
{ icon: User, label: "Name", value: company.generalManagerName }, { icon: User, label: "Name", value: company.ownerName },
{ icon: Mail, label: "Email", value: company.generalManagerEmail }, { icon: Mail, label: "Email", value: company.ownerEmail },
{ icon: Phone, label: "Phone", value: company.generalManagerPhone }, { icon: Phone, label: "Phone", value: company.ownerPhone },
]} ]}
/> />
</SectionCard> </SectionCard>

View File

@@ -43,9 +43,17 @@ export const FIELD_LABELS: Record<string, string> = {
contactPersonPosition: "Contact position", contactPersonPosition: "Contact position",
contactPersonEmail: "Contact email", contactPersonEmail: "Contact email",
contactPersonPhone: "Contact phone", contactPersonPhone: "Contact phone",
generalManagerName: "General manager", ownerName: "Owner name",
generalManagerEmail: "GM email", ownerEmail: "Owner email",
generalManagerPhone: "GM phone", ownerPhone: "Owner phone",
poaDeclared: "Has a Power of Attorney",
poaPassportNumber: "PoA passport number",
// Nothing writes these any more — the general manager was removed — but
// change requests filed before that still carry them, and without a label
// the reviewer sees a raw attribute key.
generalManagerName: "General manager (retired)",
generalManagerEmail: "GM email (retired)",
generalManagerPhone: "GM phone (retired)",
poaName: "PoA name", poaName: "PoA name",
poaPhone: "PoA phone", poaPhone: "PoA phone",
poaEmail: "PoA email", poaEmail: "PoA email",
@@ -79,9 +87,9 @@ export function currentValue(company: Company, key: string): string {
nationality: c.nationality, nationality: c.nationality,
contactPersonName: c.contactPersonName ?? attrs.contactPersonName, contactPersonName: c.contactPersonName ?? attrs.contactPersonName,
contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone, contactPersonPhone: c.contactPersonPhone ?? attrs.contactPersonPhone,
generalManagerName: c.generalManagerName ?? attrs.generalManagerName, ownerName: c.ownerName ?? attrs.ownerName,
generalManagerEmail: c.generalManagerEmail ?? attrs.generalManagerEmail, ownerEmail: c.ownerEmail ?? attrs.ownerEmail,
generalManagerPhone: c.generalManagerPhone ?? attrs.generalManagerPhone, ownerPhone: c.ownerPhone ?? attrs.ownerPhone,
}; };
const v = key in map ? map[key] : (c[key] ?? attrs[key]); const v = key in map ? map[key] : (c[key] ?? attrs[key]);
return v === null || v === undefined || v === "" ? "—" : String(v); return v === null || v === undefined || v === "" ? "—" : String(v);

View File

@@ -17,6 +17,7 @@ import {
Text, Text,
} from "@mantine/core"; } from "@mantine/core";
import { import {
AlertTriangle,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Banknote, Banknote,
@@ -638,8 +639,9 @@ export default function CustomerDetailPage() {
const hasPoaDetails = poaFields.some((f) => f.value?.trim()); const hasPoaDetails = poaFields.some((f) => f.value?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so // Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces. // this page can never disagree with the rule the API actually enforces.
const ownerIdentity = company?.identity?.owner; const identityState = company?.identity;
const poaIdentity = company?.identity?.poa; const ownerIdentity = identityState?.owner;
const poaIdentity = identityState?.poa;
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim()); const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details // A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional. // and DARS delegation paper both — is mandatory rather than optional.
@@ -647,7 +649,7 @@ export default function CustomerDetailPage() {
(p) => p.type === "freight_forwarder", (p) => p.type === "freight_forwarder",
); );
const delegationMissing = const delegationMissing =
(hasPoaDetails || poaMandatory) && poaLive.length === 0; company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
if (isLoading) { if (isLoading) {
return ( return (
@@ -834,18 +836,9 @@ export default function CustomerDetailPage() {
value={company.contactPersonPhone} value={company.contactPersonPhone}
/> />
<Box /> <Box />
<InfoField <InfoField label="Owner" value={company.ownerName} />
label="General manager" <InfoField label="Owner email" value={company.ownerEmail} />
value={company.generalManagerName} <InfoField label="Owner phone" value={company.ownerPhone} />
/>
<InfoField
label="GM email"
value={company.generalManagerEmail}
/>
<InfoField
label="GM phone"
value={company.generalManagerPhone}
/>
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
</Card> </Card>
@@ -912,6 +905,11 @@ export default function CustomerDetailPage() {
<Text fw={600} c="edr-text"> <Text fw={600} c="edr-text">
Owner identity Owner identity
</Text> </Text>
{identityState?.subject === "owner" && (
<Badge size="sm" color="blue" variant="light">
Verifies for this company
</Badge>
)}
{ownerIdentity?.verified ? ( {ownerIdentity?.verified ? (
<Badge size="sm" color="edr-green" variant="light"> <Badge size="sm" color="edr-green" variant="light">
Fayda verified Fayda verified
@@ -922,6 +920,36 @@ export default function CustomerDetailPage() {
</Badge> </Badge>
)} )}
</Group> </Group>
{/* THE check: is the owner the company put forward the person
the eTrade licence actually names? Advisory — eTrade and
Fayda transliterate Amharic names differently, so this is a
prompt to look, not a verdict. */}
{identityState?.ownerMatchesEtrade === false ? (
<Alert
color="amber"
variant="light"
icon={<AlertTriangle size={16} />}
title="Does not match the eTrade licence"
>
The licence names{" "}
<strong>{identityState.etradeManagerName}</strong>, but this
company recorded <strong>{company.ownerName}</strong>.
</Alert>
) : identityState?.ownerMatchesEtrade === true ? (
<Badge
size="sm"
color="edr-green"
variant="light"
style={{ alignSelf: "flex-start" }}
>
Matches the eTrade licence
</Badge>
) : (
<Text size="xs" c="dimmed">
No eTrade manager name on file to compare against.
</Text>
)}
{ownerIdentity?.verified ? ( {ownerIdentity?.verified ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg"> <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
<InfoField label="Name" value={ownerIdentity.name} /> <InfoField label="Name" value={ownerIdentity.name} />

View File

@@ -24,7 +24,7 @@ const cleanParams = (params: object) =>
), ),
); );
/** Lift attributes JSONB into the flat contact/manager fields the UI reads. */ /** Lift attributes JSONB into the flat contact/owner fields the UI reads. */
function mapCompany(dto: Record<string, unknown>): Company { function mapCompany(dto: Record<string, unknown>): Company {
const attrs = (dto.attributes as Record<string, unknown> | null) ?? {}; const attrs = (dto.attributes as Record<string, unknown> | null) ?? {};
return { return {
@@ -32,9 +32,9 @@ function mapCompany(dto: Record<string, unknown>): Company {
companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [], companyProfiles: (dto.companyProfiles as Company["companyProfiles"]) ?? [],
contactPersonName: (attrs.contactPersonName as string | null) ?? null, contactPersonName: (attrs.contactPersonName as string | null) ?? null,
contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null, contactPersonPhone: (attrs.contactPersonPhone as string | null) ?? null,
generalManagerName: (attrs.generalManagerName as string | null) ?? null, ownerName: (attrs.ownerName as string | null) ?? null,
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null, ownerEmail: (attrs.ownerEmail as string | null) ?? null,
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null, ownerPhone: (attrs.ownerPhone as string | null) ?? null,
poaName: (attrs.poaName as string | null) ?? null, poaName: (attrs.poaName as string | null) ?? null,
poaEmail: (attrs.poaEmail as string | null) ?? null, poaEmail: (attrs.poaEmail as string | null) ?? null,
poaPhone: (attrs.poaPhone as string | null) ?? null, poaPhone: (attrs.poaPhone as string | null) ?? null,

View File

@@ -64,9 +64,9 @@ export interface BookingCompany {
email?: string | null; email?: string | null;
contactPersonName?: string | null; contactPersonName?: string | null;
contactPersonPhone?: string | null; contactPersonPhone?: string | null;
generalManagerName?: string | null; ownerName?: string | null;
generalManagerEmail?: string | null; ownerEmail?: string | null;
generalManagerPhone?: string | null; ownerPhone?: string | null;
website?: string | null; website?: string | null;
} }

View File

@@ -179,23 +179,34 @@ export interface IdentityVerificationState {
verifiedAt: string | null; verifiedAt: string | null;
birthdate: string | null; birthdate: string | null;
gender: string | null; gender: string | null;
} /** Typed passport number — the foreign-company alternative to Fayda. */
/** Mirrors `OwnerIdentityStateDto`. */
export interface OwnerIdentityState extends IdentityVerificationState {
passportNumber: string | null; passportNumber: string | null;
} }
/** /**
* Owner/PoA Fayda verification, shared with the portal's derivation * The company's single identity verification, shared with the portal's derivation
* (`buildCompanyIdentityState`) so backoffice never re-derives — or * (`buildCompanyIdentityState`) so backoffice never re-derives — or
* disagrees with — the rule the API actually enforces. * disagrees with — the rule the API actually enforces.
*/ */
export interface CompanyIdentityState { export interface CompanyIdentityState {
faydaRequired: boolean; /** Foreign company: a passport number proves the person as Fayda would. */
passportRequired: boolean; passportAccepted: boolean;
owner: OwnerIdentityState; /** Whether the company named a representative. Null = never answered. */
poaDeclared: "yes" | "no" | null;
/** Whose verification the company is gated on — PoA if declared, else owner. */
subject: "owner" | "poa" | null;
owner: IdentityVerificationState;
poa: IdentityVerificationState; poa: IdentityVerificationState;
identityProven: boolean;
/** The manager named on the eTrade licence, captured at lookup. */
etradeManagerName: string | null;
/**
* Does the owner the company put forward match the eTrade licence?
* THE reviewer check. Null when there is nothing to compare. Advisory —
* eTrade and Fayda transliterate Amharic names differently, so a `false` is
* "look at this", not "reject this".
*/
ownerMatchesEtrade: boolean | null;
complete: boolean; complete: boolean;
} }
@@ -216,9 +227,10 @@ export interface Company {
email?: string | null; email?: string | null;
contactPersonName?: string | null; contactPersonName?: string | null;
contactPersonPhone?: string | null; contactPersonPhone?: string | null;
generalManagerName?: string | null; /** The owner — whoever the eTrade licence names as the business's manager. */
generalManagerEmail?: string | null; ownerName?: string | null;
generalManagerPhone?: string | null; ownerEmail?: string | null;
ownerPhone?: string | null;
poaName?: string | null; poaName?: string | null;
poaEmail?: string | null; poaEmail?: string | null;
poaPhone?: string | null; poaPhone?: string | null;