diff --git a/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx
new file mode 100644
index 000000000..b4a201fd1
--- /dev/null
+++ b/apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx
@@ -0,0 +1,112 @@
+import { Badge, Card, Divider, Group, Stack, Text } from "@mantine/core";
+import type { ReactNode } from "react";
+
+export interface PersonField {
+ label: string;
+ value?: string | null;
+}
+
+export interface PersonCardProps {
+ /** OWNER / POA / CONTACT PERSON — the person's role, not their name. */
+ title: string;
+ icon?: ReactNode;
+ /**
+ * Identity state. `undefined` = this person has no identity check at all
+ * (contact person), so no badge is rendered rather than a misleading "not
+ * verified" one.
+ */
+ verified?: boolean;
+ /** Extra pills after the verification badge (e.g. "Verifies for this company"). */
+ badges?: ReactNode;
+ /** Rendered between the header and the fields — alerts, match warnings. */
+ notice?: ReactNode;
+ fields: PersonField[];
+ /** Shown when the API returned nothing for every field. */
+ emptyMessage: string;
+ /** Attachments or anything else that belongs to this person. */
+ children?: ReactNode;
+}
+
+/**
+ * One person in the customer's people column: owner, power of attorney, contact
+ * person. Empty fields are dropped rather than rendered as "—", so a field the
+ * API stops sending simply disappears instead of leaving a dead row behind.
+ */
+export function PersonCard({
+ title,
+ icon,
+ verified,
+ badges,
+ notice,
+ fields,
+ emptyMessage,
+ children,
+}: PersonCardProps) {
+ const filled = fields.filter(
+ (f) => f.value != null && String(f.value).trim(),
+ );
+
+ return (
+
+
+
+ {icon}
+
+ {title}
+
+ {verified !== undefined &&
+ (verified ? (
+
+ Fayda verified
+
+ ) : (
+
+ Not verified
+
+ ))}
+ {badges}
+
+
+ {notice}
+
+ {filled.length > 0 ? (
+
+ {filled.map((f) => (
+
+
+ {f.label}
+
+
+ {f.value}
+
+
+ ))}
+
+ ) : (
+
+ {emptyMessage}
+
+ )}
+
+ {children && (
+ <>
+
+ {children}
+ >
+ )}
+
+
+ );
+}
+
+export default PersonCard;
diff --git a/apps/edr-freight-web/backoffice/src/components/customers/index.ts b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
index 81f25fcb2..daeb11311 100644
--- a/apps/edr-freight-web/backoffice/src/components/customers/index.ts
+++ b/apps/edr-freight-web/backoffice/src/components/customers/index.ts
@@ -24,4 +24,9 @@ export {
type ResetPasswordActionProps,
} from "./ResetPasswordAction";
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
+export {
+ PersonCard,
+ type PersonCardProps,
+ type PersonField,
+} from "./PersonCard";
export { TableCard, type TableCardProps } from "./TableCard";
diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
index a6367786a..73f6a618b 100644
--- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
+++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx
@@ -8,7 +8,7 @@ import {
Card,
Center,
Container,
- Divider,
+ Grid,
Group,
Loader,
SimpleGrid,
@@ -21,6 +21,7 @@ import {
ArrowLeft,
ArrowRight,
Banknote,
+ Contact,
Download,
Eye,
FileText,
@@ -32,6 +33,8 @@ import {
FilePen,
Paperclip,
Receipt,
+ UserCheck,
+ UserRound,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
@@ -46,6 +49,7 @@ import {
CompanyTypeBadge,
InvoiceStatusBadge,
PaymentStatusBadge,
+ PersonCard,
ProfileApprovalActions,
ProfileChips,
ProfileStatusBadge,
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
variant="subtle"
color="gray"
aria-label={`View ${f.name}`}
- onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
+ onClick={() =>
+ void fetchViewableFile(f.id, f.name).then(view)
+ }
>
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
type="button"
size="xs"
lineClamp={1}
- onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
+ onClick={() =>
+ void fetchViewableFile(f.id, f.name).then(view)
+ }
style={{
maxWidth: 170,
textAlign: "left",
@@ -615,7 +623,10 @@ export default function CustomerDetailPage() {
[],
);
- const licenseProfiles = (company?.companyProfiles ?? []).filter(
+ /** Never read `company.companyProfiles` directly — an endpoint that stops
+ * loading the relation would otherwise crash the whole page. */
+ const profiles = company?.companyProfiles ?? [];
+ const licenseProfiles = profiles.filter(
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
[documents],
);
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
- const poaFields = [
- { label: "PoA name", value: company?.poaName },
- { label: "PoA email", value: company?.poaEmail },
- { label: "PoA phone", value: company?.poaPhone },
- { label: "PoA location", value: company?.poaLocation },
- { label: "PoA address", value: company?.poaAddress },
- ];
- const hasPoaDetails = poaFields.some((f) => f.value?.trim());
+ const hasPoaDetails = [
+ company?.poaName,
+ company?.poaEmail,
+ company?.poaPhone,
+ company?.poaLocation,
+ company?.poaAddress,
+ ].some((v) => v?.trim());
// Shared with the portal (buildCompanyIdentityState) — same derivation, so
// this page can never disagree with the rule the API actually enforces.
const identityState = company?.identity;
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
const hasEtradeRecord = Boolean(company?.licenceNumber?.trim());
// A freight forwarder acts on other companies' behalf, so its PoA — details
// and DARS delegation paper both — is mandatory rather than optional.
- const poaMandatory = (company?.companyProfiles ?? []).some(
- (p) => p.type === "freight_forwarder",
- );
+ const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
const delegationMissing =
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
]}
backTo="/dashboard/customers"
title={company.name}
- subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
- }`}
+ subtitle={`TIN ${company.tin}${
+ company.country ? ` · ${company.country}` : ""
+ }`}
meta={
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
items={[
{
label: "Profiles",
- value: company.companyProfiles.length,
+ value: profiles.length,
icon: IdCard,
color: "edr-green",
},
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
: "Pending approval",
value: stillOnboarding
? "—"
- : company.companyProfiles.filter(
- (p) => p.status === "pending",
- ).length,
+ : profiles.filter((p) => p.status === "pending").length,
icon: IdCard,
color: "yellow",
},
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
]}
/>
-
-
-
- Company information
-
-
-
-
-
-
-
-
-
-
- {/* Why this company's registration was typed rather than
- fetched, and why it carries no business licence. */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- eTrade registration
-
- {hasEtradeRecord ? (
-
- Verified with eTrade
-
- ) : (
-
- No eTrade record
-
- )}
-
- {hasEtradeRecord && (
- downloadTinRecord(company)}
- >
-
-
- )}
-
- {hasEtradeRecord ? (
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) : (
-
- No eTrade registration record on file for this customer's
- TIN.
-
- )}
-
-
-
-
-
-
-
- Owner identity
-
- {identityState?.subject === "owner" && (
-
- Verifies for this company
-
- )}
- {ownerIdentity?.verified ? (
-
- Fayda verified
-
- ) : (
-
- Not verified
-
- )}
-
-
- {/* 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 ? (
- }
- title="Does not match the eTrade licence"
- >
- The licence names{" "}
- {identityState.etradeManagerName}, but this
- company recorded {company.ownerName}.
-
- ) : identityState?.ownerMatchesEtrade === true ? (
-
- Matches the eTrade licence
-
- ) : company.cooperative ? (
-
- A co-operative union or farm holds no trade licence, so
- there is no eTrade record to check the owner against.
-
- ) : (
-
- No eTrade manager name on file to compare against.
-
- )}
- {ownerIdentity?.verified ? (
-
-
-
-
-
-
-
-
-
-
- ) : (
-
- {ownerIdentity?.passportNumber
- ? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
- : "The company owner has not verified their identity with Fayda."}
-
- )}
-
-
-
-
-
-
-
-
- Power of Attorney
-
- {poaMandatory && (
-
- Required for freight forwarder
-
- )}
-
- {delegationMissing ? (
-
- DARS delegation paper missing
-
- ) : poaLive.length > 0 ? (
-
- DARS delegation paper on file
-
- ) : (
-
- Not provided
-
- )}
-
-
- {hasPoaDetails ? (
-
- {poaFields.map((f) => (
-
- ))}
-
- {poaIdentity?.verified && (
- <>
+
+ {/* Company facts — the wide column. People live in the narrow one
+ beside it, so nothing about a person is stated twice. */}
+
+
+
+
+
+ Company information
+
+
+
+
+
+ {/* Why this company's registration was typed rather
+ than fetched, and why it carries no licence. */}
+
+
+
+
+
+
-
- >
- )}
-
- ) : (
-
- No Power of Attorney representative recorded for this
- customer.
-
- )}
+
+
+
-
-
-
-
- DARS delegation paper
-
-
- {documentsQuery.isLoading ? (
-
-
-
- Loading documents…
-
-
- ) : documentsQuery.isError ? (
-
-
- Failed to load documents.
-
- void documentsQuery.refetch()}
- >
- Retry
-
-
- ) : poaDocuments.length === 0 ? (
-
- No DARS delegation paper uploaded.
-
- ) : (
- poaDocuments.map((doc) => (
-
-
-
-
- void fetchViewableFile(doc.id, doc.name).then(view)
- }
- >
- {doc.name}
-
-
- {formatBytes(doc.size)} ·{" "}
- {formatDate(doc.uploadedAt)}
+
+
+
+
+
+ eTrade registration
- {doc.code === POA_DELEGATION_PENDING_CODE && (
-
- Pending approval
+ {hasEtradeRecord ? (
+
+ Verified with eTrade
+
+ ) : (
+
+ No eTrade record
)}
-
+ {hasEtradeRecord && (
- void fetchViewableFile(doc.id, doc.name).then(view)
- }
- >
-
-
-
- void downloadBookingFile(doc.id, doc.name)
- }
- variant="subtle"
- color="gray"
- aria-label={`Download ${doc.name}`}
+ variant="default"
+ aria-label="Download TIN record"
+ onClick={() => downloadTinRecord(company)}
>
-
+ )}
- ))
- )}
-
-
-
+ {hasEtradeRecord ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ No eTrade registration record on file for this
+ customer's TIN.
+
+ )}
+
+
-
-
-
-
- Role profiles
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ Role profiles
+
+
+
+ {/* Narrower than the old full-width layout — the table
+ shares the row with the people column now. */}
+
+
+
+
+
+
+
+
+
+
+ {/* People: owner, then power of attorney, then contact person —
+ the order a reviewer checks them in. */}
+
+
+ }
+ verified={Boolean(ownerIdentity?.verified)}
+ badges={
+ <>
+ {identityState?.subject === "owner" && (
+
+ Verifies for this company
+
+ )}
+ {identityState?.ownerMatchesEtrade === true && (
+
+ Matches eTrade licence
+
+ )}
+ >
+ }
+ notice={
+ /* 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 ? (
+ }
+ title="Does not match the eTrade licence"
+ >
+
+ The licence names{" "}
+ {identityState.etradeManagerName},
+ but this company recorded{" "}
+ {company.ownerName ?? "nobody"}.
+
+
+ ) : !ownerIdentity?.verified &&
+ ownerIdentity?.passportNumber ? (
+
+ Identified by passport rather than Fayda.
+
+ ) : null
+ }
+ fields={[
+ {
+ label: "Name",
+ value: company.ownerName ?? ownerIdentity?.name,
+ },
+ {
+ label: "Email",
+ value: company.ownerEmail ?? ownerIdentity?.email,
+ },
+ {
+ label: "Phone",
+ value: company.ownerPhone ?? ownerIdentity?.phone,
+ },
+ {
+ label: "Passport number",
+ value: ownerIdentity?.passportNumber,
+ },
+ { label: "Address", value: ownerIdentity?.address },
+ { label: "Birthdate", value: ownerIdentity?.birthdate },
+ { label: "Gender", value: ownerIdentity?.gender },
+ {
+ label: "Verified on",
+ value: ownerIdentity?.verifiedAt
+ ? formatDate(ownerIdentity.verifiedAt)
+ : null,
+ },
+ ]}
+ emptyMessage="No owner recorded for this company."
+ />
+
+ }
+ verified={
+ // No PoA at all → no badge, rather than a "not verified"
+ // that reads as a problem where none exists.
+ hasPoaDetails || identityState?.poaDeclared === "yes"
+ ? Boolean(poaIdentity?.verified)
+ : undefined
+ }
+ badges={
+ <>
+ {identityState?.subject === "poa" && (
+
+ Verifies for this company
+
+ )}
+ {poaMandatory && (
+
+ Required for freight forwarder
+
+ )}
+ {delegationMissing && (
+
+ Delegation paper missing
+
+ )}
+ >
+ }
+ fields={[
+ { label: "Name", value: company.poaName },
+ { label: "Email", value: company.poaEmail },
+ { label: "Phone", value: company.poaPhone },
+ { label: "Location", value: company.poaLocation },
+ { label: "Address", value: company.poaAddress },
+ { label: "Birthdate", value: poaIdentity?.birthdate },
+ { label: "Gender", value: poaIdentity?.gender },
+ {
+ label: "Verified on",
+ value: poaIdentity?.verifiedAt
+ ? formatDate(poaIdentity.verifiedAt)
+ : null,
+ },
+ ]}
+ emptyMessage="No representative recorded for this customer."
+ >
+
+
+ DARS delegation paper
+
+
+ {documentsQuery.isLoading ? (
+
+
+
+ Loading…
+
+
+ ) : documentsQuery.isError ? (
+
+
+ Failed to load documents.
+
+ void documentsQuery.refetch()}
+ >
+ Retry
+
+
+ ) : poaDocuments.length === 0 ? (
+
+ Not uploaded.
+
+ ) : (
+ poaDocuments.map((doc) => (
+
+
+
+
+ void fetchViewableFile(doc.id, doc.name).then(
+ view,
+ )
+ }
+ >
+ {doc.name}
+
+
+ void fetchViewableFile(doc.id, doc.name).then(
+ view,
+ )
+ }
+ >
+
+
+
+ void downloadBookingFile(doc.id, doc.name)
+ }
+ >
+
+
+
+
+
+ {formatBytes(doc.size)} ·{" "}
+ {formatDate(doc.uploadedAt)}
+
+ {doc.code === POA_DELEGATION_PENDING_CODE && (
+
+ Pending approval
+
+ )}
+
+
+ ))
+ )}
+
+
+
+ }
+ fields={[
+ { label: "Name", value: company.contactPersonName },
+ { label: "Phone", value: company.contactPersonPhone },
+ ]}
+ emptyMessage="No contact person recorded."
+ />
+
+
+
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
error={
bookingsQuery.isError
? {
- message: "Failed to load bookings.",
- onRetry: () => void bookingsQuery.refetch(),
- }
+ message: "Failed to load bookings.",
+ onRetry: () => void bookingsQuery.refetch(),
+ }
: undefined
}
/>
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
error={
documentsQuery.isError
? {
- message: "Failed to load documents.",
- onRetry: () => void documentsQuery.refetch(),
- }
+ message: "Failed to load documents.",
+ onRetry: () => void documentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
error={
paymentsQuery.isError
? {
- message: "Failed to load payments.",
- onRetry: () => void paymentsQuery.refetch(),
- }
+ message: "Failed to load payments.",
+ onRetry: () => void paymentsQuery.refetch(),
+ }
: undefined
}
/>
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
error={
invoicesQuery.isError
? {
- message: "Failed to load invoices.",
- onRetry: () => void invoicesQuery.refetch(),
- }
+ message: "Failed to load invoices.",
+ onRetry: () => void invoicesQuery.refetch(),
+ }
: undefined
}
pagination={{