From 068ee49a9f39b682bd669d702cf1a5b18b040d42 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Wed, 12 Aug 2026 09:33:21 +0000 Subject: [PATCH] style: invoice --- .../src/components/customers/PersonCard.tsx | 112 +++ .../src/components/customers/index.ts | 5 + .../pages/customers/CustomerDetailPage.tsx | 830 +++++++++--------- 3 files changed, 528 insertions(+), 419 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/customers/PersonCard.tsx 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={{