mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
style: invoice
This commit is contained in:
@@ -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 (
|
||||||
|
<Card>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap={8} wrap="wrap">
|
||||||
|
{icon}
|
||||||
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={700}
|
||||||
|
c="edr-muted"
|
||||||
|
tt="uppercase"
|
||||||
|
style={{ letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
{verified !== undefined &&
|
||||||
|
(verified ? (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Fayda verified
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge size="xs" color="gray" variant="light">
|
||||||
|
Not verified
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{badges}
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{notice}
|
||||||
|
|
||||||
|
{filled.length > 0 ? (
|
||||||
|
<Stack gap="xs">
|
||||||
|
{filled.map((f) => (
|
||||||
|
<Stack key={f.label} gap={0}>
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
{f.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="edr-text"
|
||||||
|
style={{ wordBreak: "break-word" }}
|
||||||
|
>
|
||||||
|
{f.value}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PersonCard;
|
||||||
@@ -24,4 +24,9 @@ export {
|
|||||||
type ResetPasswordActionProps,
|
type ResetPasswordActionProps,
|
||||||
} from "./ResetPasswordAction";
|
} from "./ResetPasswordAction";
|
||||||
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
export { formatBytes, formatDate, formatMoney, humanize } from "./format";
|
||||||
|
export {
|
||||||
|
PersonCard,
|
||||||
|
type PersonCardProps,
|
||||||
|
type PersonField,
|
||||||
|
} from "./PersonCard";
|
||||||
export { TableCard, type TableCardProps } from "./TableCard";
|
export { TableCard, type TableCardProps } from "./TableCard";
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
Grid,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
|
Contact,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -32,6 +33,8 @@ import {
|
|||||||
FilePen,
|
FilePen,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Receipt,
|
Receipt,
|
||||||
|
UserCheck,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -46,6 +49,7 @@ import {
|
|||||||
CompanyTypeBadge,
|
CompanyTypeBadge,
|
||||||
InvoiceStatusBadge,
|
InvoiceStatusBadge,
|
||||||
PaymentStatusBadge,
|
PaymentStatusBadge,
|
||||||
|
PersonCard,
|
||||||
ProfileApprovalActions,
|
ProfileApprovalActions,
|
||||||
ProfileChips,
|
ProfileChips,
|
||||||
ProfileStatusBadge,
|
ProfileStatusBadge,
|
||||||
@@ -259,7 +263,9 @@ export default function CustomerDetailPage() {
|
|||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
aria-label={`View ${f.name}`}
|
aria-label={`View ${f.name}`}
|
||||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Eye size={14} />
|
<Eye size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -268,7 +274,9 @@ export default function CustomerDetailPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
size="xs"
|
size="xs"
|
||||||
lineClamp={1}
|
lineClamp={1}
|
||||||
onClick={() => void fetchViewableFile(f.id, f.name).then(view)}
|
onClick={() =>
|
||||||
|
void fetchViewableFile(f.id, f.name).then(view)
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
maxWidth: 170,
|
maxWidth: 170,
|
||||||
textAlign: "left",
|
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,
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -629,14 +640,13 @@ export default function CustomerDetailPage() {
|
|||||||
[documents],
|
[documents],
|
||||||
);
|
);
|
||||||
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
const poaLive = poaDocuments.filter((d) => d.code === POA_DELEGATION_CODE);
|
||||||
const poaFields = [
|
const hasPoaDetails = [
|
||||||
{ label: "PoA name", value: company?.poaName },
|
company?.poaName,
|
||||||
{ label: "PoA email", value: company?.poaEmail },
|
company?.poaEmail,
|
||||||
{ label: "PoA phone", value: company?.poaPhone },
|
company?.poaPhone,
|
||||||
{ label: "PoA location", value: company?.poaLocation },
|
company?.poaLocation,
|
||||||
{ label: "PoA address", value: company?.poaAddress },
|
company?.poaAddress,
|
||||||
];
|
].some((v) => v?.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 identityState = company?.identity;
|
const identityState = company?.identity;
|
||||||
@@ -645,9 +655,7 @@ export default function CustomerDetailPage() {
|
|||||||
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.
|
||||||
const poaMandatory = (company?.companyProfiles ?? []).some(
|
const poaMandatory = profiles.some((p) => p.type === "freight_forwarder");
|
||||||
(p) => p.type === "freight_forwarder",
|
|
||||||
);
|
|
||||||
const delegationMissing =
|
const delegationMissing =
|
||||||
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
company?.identity?.poaDeclared === "yes" && poaLive.length === 0;
|
||||||
|
|
||||||
@@ -685,8 +693,9 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
backTo="/dashboard/customers"
|
backTo="/dashboard/customers"
|
||||||
title={company.name}
|
title={company.name}
|
||||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
subtitle={`TIN ${company.tin}${
|
||||||
}`}
|
company.country ? ` · ${company.country}` : ""
|
||||||
|
}`}
|
||||||
meta={
|
meta={
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<CompanyTypeBadge type={company.type} />
|
<CompanyTypeBadge type={company.type} />
|
||||||
@@ -749,7 +758,7 @@ export default function CustomerDetailPage() {
|
|||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
label: "Profiles",
|
label: "Profiles",
|
||||||
value: company.companyProfiles.length,
|
value: profiles.length,
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
color: "edr-green",
|
color: "edr-green",
|
||||||
},
|
},
|
||||||
@@ -761,9 +770,7 @@ export default function CustomerDetailPage() {
|
|||||||
: "Pending approval",
|
: "Pending approval",
|
||||||
value: stillOnboarding
|
value: stillOnboarding
|
||||||
? "—"
|
? "—"
|
||||||
: company.companyProfiles.filter(
|
: profiles.filter((p) => p.status === "pending").length,
|
||||||
(p) => p.status === "pending",
|
|
||||||
).length,
|
|
||||||
icon: IdCard,
|
icon: IdCard,
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
@@ -782,407 +789,392 @@ export default function CustomerDetailPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card>
|
<Grid gap="lg" align="flex-start">
|
||||||
<Stack gap="lg">
|
{/* Company facts — the wide column. People live in the narrow one
|
||||||
<Text fw={600} c="edr-text">
|
beside it, so nothing about a person is stated twice. */}
|
||||||
Company information
|
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||||
</Text>
|
<Stack gap="lg">
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
<Card>
|
||||||
<InfoField label="TIN" value={company.tin} />
|
<Stack gap="lg">
|
||||||
<InfoField label="VAT number" value={company.vatNumber} />
|
<Text fw={600} c="edr-text">
|
||||||
<InfoField label="FAN number" value={company.fanNumber} />
|
Company information
|
||||||
<InfoField
|
</Text>
|
||||||
label="Submitted on"
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="lg">
|
||||||
value={formatDate(company.createdAt)}
|
<InfoField label="TIN" value={company.tin} />
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Approved on"
|
|
||||||
value={
|
|
||||||
company.approvedAt
|
|
||||||
? formatDate(company.approvedAt)
|
|
||||||
: "Not yet approved"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Owner identity"
|
|
||||||
value={
|
|
||||||
ownerIdentity?.verified
|
|
||||||
? "Fayda verified"
|
|
||||||
: ownerIdentity?.passportNumber
|
|
||||||
? `Passport ${ownerIdentity.passportNumber}`
|
|
||||||
: "Not verified"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoField label="Country" value={company.country} />
|
|
||||||
<InfoField
|
|
||||||
label="Nationality"
|
|
||||||
value={
|
|
||||||
company.nationality
|
|
||||||
? humanize(company.nationality)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{/* Why this company's registration was typed rather than
|
|
||||||
fetched, and why it carries no business licence. */}
|
|
||||||
<InfoField
|
|
||||||
label="Registration"
|
|
||||||
value={
|
|
||||||
company.cooperative
|
|
||||||
? "Co-operative union / farm (no trade licence)"
|
|
||||||
: "eTrade trade licence"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<InfoField label="Address" value={company.address} />
|
|
||||||
<InfoField label="Website" value={company.website} />
|
|
||||||
<InfoField label="Email" value={company.email} />
|
|
||||||
<InfoField label="Phone" value={company.phone} />
|
|
||||||
<Box />
|
|
||||||
<InfoField
|
|
||||||
label="Contact person"
|
|
||||||
value={company.contactPersonName}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Contact phone"
|
|
||||||
value={company.contactPersonPhone}
|
|
||||||
/>
|
|
||||||
<Box />
|
|
||||||
<InfoField label="Owner" value={company.ownerName} />
|
|
||||||
<InfoField label="Owner email" value={company.ownerEmail} />
|
|
||||||
<InfoField label="Owner phone" value={company.ownerPhone} />
|
|
||||||
</SimpleGrid>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
eTrade registration
|
|
||||||
</Text>
|
|
||||||
{hasEtradeRecord ? (
|
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
|
||||||
Verified with eTrade
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" color="gray" variant="light">
|
|
||||||
No eTrade record
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{hasEtradeRecord && (
|
|
||||||
<ActionIcon
|
|
||||||
variant="default"
|
|
||||||
aria-label="Download TIN record"
|
|
||||||
onClick={() => downloadTinRecord(company)}
|
|
||||||
>
|
|
||||||
<Download size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{hasEtradeRecord ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
<InfoField
|
|
||||||
label="License number"
|
|
||||||
value={company.licenceNumber}
|
|
||||||
/>
|
|
||||||
<InfoField label="Status" value={company.statusDescription} />
|
|
||||||
<InfoField
|
|
||||||
label="Date registered"
|
|
||||||
value={company.dateRegistered}
|
|
||||||
/>
|
|
||||||
<InfoField label="Renewed from" value={company.renewedFrom} />
|
|
||||||
<InfoField label="Renewal date" value={company.renewalDate} />
|
|
||||||
<InfoField label="Renewed to" value={company.renewedTo} />
|
|
||||||
<InfoField label="Region" value={company.region} />
|
|
||||||
<InfoField label="Zone" value={company.zone} />
|
|
||||||
<InfoField label="Woreda" value={company.woreda} />
|
|
||||||
<InfoField label="Kebele" value={company.kebele} />
|
|
||||||
<InfoField label="House No" value={company.houseNo} />
|
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No eTrade registration record on file for this customer's
|
|
||||||
TIN.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Owner identity
|
|
||||||
</Text>
|
|
||||||
{identityState?.subject === "owner" && (
|
|
||||||
<Badge size="sm" color="blue" variant="light">
|
|
||||||
Verifies for this company
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{ownerIdentity?.verified ? (
|
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
|
||||||
Fayda verified
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" color="gray" variant="light">
|
|
||||||
Not verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
) : company.cooperative ? (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
A co-operative union or farm holds no trade licence, so
|
|
||||||
there is no eTrade record to check the owner against.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
No eTrade manager name on file to compare against.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{ownerIdentity?.verified ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
<InfoField label="Name" value={ownerIdentity.name} />
|
|
||||||
<InfoField label="Phone" value={ownerIdentity.phone} />
|
|
||||||
<InfoField label="Email" value={ownerIdentity.email} />
|
|
||||||
<InfoField label="Address" value={ownerIdentity.address} />
|
|
||||||
<InfoField
|
|
||||||
label="Verified at"
|
|
||||||
value={formatDate(ownerIdentity.verifiedAt)}
|
|
||||||
/>
|
|
||||||
<InfoField
|
|
||||||
label="Birthdate"
|
|
||||||
value={ownerIdentity.birthdate}
|
|
||||||
/>
|
|
||||||
<InfoField label="Gender" value={ownerIdentity.gender} />
|
|
||||||
<InfoField
|
|
||||||
label="Passport number"
|
|
||||||
value={ownerIdentity.passportNumber}
|
|
||||||
/>
|
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{ownerIdentity?.passportNumber
|
|
||||||
? `Not Fayda verified — identified by passport ${ownerIdentity.passportNumber}.`
|
|
||||||
: "The company owner has not verified their identity with Fayda."}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<Stack gap="lg">
|
|
||||||
<Group justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
|
||||||
<Text fw={600} c="edr-text">
|
|
||||||
Power of Attorney
|
|
||||||
</Text>
|
|
||||||
{poaMandatory && (
|
|
||||||
<Badge size="xs" color="blue" variant="light">
|
|
||||||
Required for freight forwarder
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
{delegationMissing ? (
|
|
||||||
<Badge size="sm" color="red" variant="light">
|
|
||||||
DARS delegation paper missing
|
|
||||||
</Badge>
|
|
||||||
) : poaLive.length > 0 ? (
|
|
||||||
<Badge size="sm" color="edr-green" variant="light">
|
|
||||||
DARS delegation paper on file
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge size="sm" color="gray" variant="light">
|
|
||||||
Not provided
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{hasPoaDetails ? (
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
||||||
{poaFields.map((f) => (
|
|
||||||
<InfoField
|
|
||||||
key={f.label}
|
|
||||||
label={f.label}
|
|
||||||
value={f.value}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<InfoField
|
|
||||||
label="PoA Fayda"
|
|
||||||
value={
|
|
||||||
poaIdentity?.verified ? "Verified" : "Not verified"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{poaIdentity?.verified && (
|
|
||||||
<>
|
|
||||||
<InfoField
|
<InfoField
|
||||||
label="PoA verified at"
|
label="VAT number"
|
||||||
value={formatDate(poaIdentity.verifiedAt)}
|
value={company.vatNumber}
|
||||||
|
/>
|
||||||
|
<InfoField label="Country" value={company.country} />
|
||||||
|
<InfoField
|
||||||
|
label="Nationality"
|
||||||
|
value={
|
||||||
|
company.nationality
|
||||||
|
? humanize(company.nationality)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* Why this company's registration was typed rather
|
||||||
|
than fetched, and why it carries no licence. */}
|
||||||
|
<InfoField
|
||||||
|
label="Registration"
|
||||||
|
value={
|
||||||
|
company.cooperative
|
||||||
|
? "Co-operative union / farm (no trade licence)"
|
||||||
|
: "eTrade trade licence"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InfoField label="Address" value={company.address} />
|
||||||
|
<InfoField label="Email" value={company.email} />
|
||||||
|
<InfoField label="Phone" value={company.phone} />
|
||||||
|
<InfoField label="Website" value={company.website} />
|
||||||
|
<InfoField
|
||||||
|
label="Submitted on"
|
||||||
|
value={formatDate(company.createdAt)}
|
||||||
/>
|
/>
|
||||||
<InfoField
|
<InfoField
|
||||||
label="PoA birthdate"
|
label="Approved on"
|
||||||
value={poaIdentity.birthdate}
|
value={
|
||||||
|
company.approvedAt
|
||||||
|
? formatDate(company.approvedAt)
|
||||||
|
: "Not yet approved"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<InfoField label="PoA gender" value={poaIdentity.gender} />
|
</SimpleGrid>
|
||||||
</>
|
</Stack>
|
||||||
)}
|
</Card>
|
||||||
</SimpleGrid>
|
|
||||||
) : (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No Power of Attorney representative recorded for this
|
|
||||||
customer.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
<Card>
|
||||||
|
<Stack gap="lg">
|
||||||
<Stack gap="sm">
|
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||||
<Text
|
<Group gap="xs" wrap="nowrap">
|
||||||
size="xs"
|
<Text fw={600} c="edr-text">
|
||||||
fw={600}
|
eTrade registration
|
||||||
c="edr-muted"
|
|
||||||
tt="uppercase"
|
|
||||||
style={{ letterSpacing: "0.04em" }}
|
|
||||||
>
|
|
||||||
DARS delegation paper
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{documentsQuery.isLoading ? (
|
|
||||||
<Group gap="xs">
|
|
||||||
<Loader size="xs" />
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
Loading documents…
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
) : documentsQuery.isError ? (
|
|
||||||
<Group gap="sm">
|
|
||||||
<Text size="sm" c="red">
|
|
||||||
Failed to load documents.
|
|
||||||
</Text>
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => void documentsQuery.refetch()}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Anchor>
|
|
||||||
</Group>
|
|
||||||
) : poaDocuments.length === 0 ? (
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
No DARS delegation paper uploaded.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
poaDocuments.map((doc) => (
|
|
||||||
<Group key={doc.id} justify="space-between" wrap="nowrap">
|
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Paperclip
|
|
||||||
size={14}
|
|
||||||
className="shrink-0 text-edr-muted"
|
|
||||||
/>
|
|
||||||
<Anchor
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
lineClamp={1}
|
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{doc.name}
|
|
||||||
</Anchor>
|
|
||||||
<Text size="xs" c="dimmed" className="shrink-0">
|
|
||||||
{formatBytes(doc.size)} ·{" "}
|
|
||||||
{formatDate(doc.uploadedAt)}
|
|
||||||
</Text>
|
</Text>
|
||||||
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
{hasEtradeRecord ? (
|
||||||
<Badge
|
<Badge size="sm" color="edr-green" variant="light">
|
||||||
size="xs"
|
Verified with eTrade
|
||||||
color="yellow"
|
</Badge>
|
||||||
variant="light"
|
) : (
|
||||||
className="shrink-0"
|
<Badge size="sm" color="gray" variant="light">
|
||||||
>
|
No eTrade record
|
||||||
Pending approval
|
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={4} wrap="nowrap">
|
{hasEtradeRecord && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="default"
|
||||||
color="gray"
|
aria-label="Download TIN record"
|
||||||
aria-label={`Preview ${doc.name}`}
|
onClick={() => downloadTinRecord(company)}
|
||||||
onClick={() =>
|
|
||||||
void fetchViewableFile(doc.id, doc.name).then(view)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Eye size={16} />
|
|
||||||
</ActionIcon>
|
|
||||||
<ActionIcon
|
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
void downloadBookingFile(doc.id, doc.name)
|
|
||||||
}
|
|
||||||
variant="subtle"
|
|
||||||
color="gray"
|
|
||||||
aria-label={`Download ${doc.name}`}
|
|
||||||
>
|
>
|
||||||
<Download size={16} />
|
<Download size={16} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
))
|
{hasEtradeRecord ? (
|
||||||
)}
|
<SimpleGrid
|
||||||
</Stack>
|
cols={{ base: 1, sm: 2, lg: 3 }}
|
||||||
</Stack>
|
spacing="lg"
|
||||||
</Card>
|
>
|
||||||
|
<InfoField
|
||||||
|
label="License number"
|
||||||
|
value={company.licenceNumber}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Status"
|
||||||
|
value={company.statusDescription}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Date registered"
|
||||||
|
value={company.dateRegistered}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Renewed from"
|
||||||
|
value={company.renewedFrom}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Renewal date"
|
||||||
|
value={company.renewalDate}
|
||||||
|
/>
|
||||||
|
<InfoField
|
||||||
|
label="Renewed to"
|
||||||
|
value={company.renewedTo}
|
||||||
|
/>
|
||||||
|
<InfoField label="Region" value={company.region} />
|
||||||
|
<InfoField label="Zone" value={company.zone} />
|
||||||
|
<InfoField label="Woreda" value={company.woreda} />
|
||||||
|
<InfoField label="Kebele" value={company.kebele} />
|
||||||
|
<InfoField label="House No" value={company.houseNo} />
|
||||||
|
</SimpleGrid>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
No eTrade registration record on file for this
|
||||||
|
customer's TIN.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={600} c="edr-text">
|
<Text fw={600} c="edr-text">
|
||||||
Role profiles
|
Role profiles
|
||||||
</Text>
|
</Text>
|
||||||
<ProfileChips profiles={company.companyProfiles} />
|
<ProfileChips profiles={profiles} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box style={{ overflowX: "auto" }} w="100%">
|
{/* Narrower than the old full-width layout — the table
|
||||||
<Box miw={1040}>
|
shares the row with the people column now. */}
|
||||||
<DataTable
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
columns={profileColumns}
|
<Box miw={760}>
|
||||||
data={company.companyProfiles}
|
<DataTable
|
||||||
status="success"
|
columns={profileColumns}
|
||||||
emptyMessage="No profiles registered."
|
data={profiles}
|
||||||
containerClassName="border-0 shadow-none bg-transparent"
|
status="success"
|
||||||
/>
|
emptyMessage="No profiles registered."
|
||||||
</Box>
|
containerClassName="border-0 shadow-none bg-transparent"
|
||||||
</Box>
|
/>
|
||||||
</Stack>
|
</Box>
|
||||||
</Card>
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
{/* People: owner, then power of attorney, then contact person —
|
||||||
|
the order a reviewer checks them in. */}
|
||||||
|
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<PersonCard
|
||||||
|
title="Owner"
|
||||||
|
icon={<UserRound size={15} className="text-edr-muted" />}
|
||||||
|
verified={Boolean(ownerIdentity?.verified)}
|
||||||
|
badges={
|
||||||
|
<>
|
||||||
|
{identityState?.subject === "owner" && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Verifies for this company
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{identityState?.ownerMatchesEtrade === true && (
|
||||||
|
<Badge size="xs" color="edr-green" variant="light">
|
||||||
|
Matches eTrade licence
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
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 ? (
|
||||||
|
<Alert
|
||||||
|
color="amber"
|
||||||
|
variant="light"
|
||||||
|
p="xs"
|
||||||
|
icon={<AlertTriangle size={16} />}
|
||||||
|
title="Does not match the eTrade licence"
|
||||||
|
>
|
||||||
|
<Text size="xs">
|
||||||
|
The licence names{" "}
|
||||||
|
<strong>{identityState.etradeManagerName}</strong>,
|
||||||
|
but this company recorded{" "}
|
||||||
|
<strong>{company.ownerName ?? "nobody"}</strong>.
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
) : !ownerIdentity?.verified &&
|
||||||
|
ownerIdentity?.passportNumber ? (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Identified by passport rather than Fayda.
|
||||||
|
</Text>
|
||||||
|
) : 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."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PersonCard
|
||||||
|
title="Power of attorney"
|
||||||
|
icon={<UserCheck size={15} className="text-edr-muted" />}
|
||||||
|
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" && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Verifies for this company
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{poaMandatory && (
|
||||||
|
<Badge size="xs" color="blue" variant="light">
|
||||||
|
Required for freight forwarder
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{delegationMissing && (
|
||||||
|
<Badge size="xs" color="red" variant="light">
|
||||||
|
Delegation paper missing
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
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."
|
||||||
|
>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="xs" c="edr-muted">
|
||||||
|
DARS delegation paper
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{documentsQuery.isLoading ? (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="xs" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading…
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : documentsQuery.isError ? (
|
||||||
|
<Group gap="sm">
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Failed to load documents.
|
||||||
|
</Text>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="xs"
|
||||||
|
onClick={() => void documentsQuery.refetch()}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
) : poaDocuments.length === 0 ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Not uploaded.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
poaDocuments.map((doc) => (
|
||||||
|
<Stack key={doc.id} gap={2}>
|
||||||
|
<Group gap={6} wrap="nowrap">
|
||||||
|
<Paperclip
|
||||||
|
size={13}
|
||||||
|
className="shrink-0 text-edr-muted"
|
||||||
|
/>
|
||||||
|
<Anchor
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ flex: 1, textAlign: "left" }}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(doc.id, doc.name).then(
|
||||||
|
view,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{doc.name}
|
||||||
|
</Anchor>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Preview ${doc.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
void fetchViewableFile(doc.id, doc.name).then(
|
||||||
|
view,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
aria-label={`Download ${doc.name}`}
|
||||||
|
onClick={() =>
|
||||||
|
void downloadBookingFile(doc.id, doc.name)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download size={15} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
<Group gap={6} pl={19} wrap="wrap">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{formatBytes(doc.size)} ·{" "}
|
||||||
|
{formatDate(doc.uploadedAt)}
|
||||||
|
</Text>
|
||||||
|
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
||||||
|
<Badge size="xs" color="yellow" variant="light">
|
||||||
|
Pending approval
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</PersonCard>
|
||||||
|
|
||||||
|
<PersonCard
|
||||||
|
title="Contact person"
|
||||||
|
icon={<Contact size={15} className="text-edr-muted" />}
|
||||||
|
fields={[
|
||||||
|
{ label: "Name", value: company.contactPersonName },
|
||||||
|
{ label: "Phone", value: company.contactPersonPhone },
|
||||||
|
]}
|
||||||
|
emptyMessage="No contact person recorded."
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -1198,9 +1190,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
bookingsQuery.isError
|
bookingsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load bookings.",
|
message: "Failed to load bookings.",
|
||||||
onRetry: () => void bookingsQuery.refetch(),
|
onRetry: () => void bookingsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1220,9 +1212,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
documentsQuery.isError
|
documentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load documents.",
|
message: "Failed to load documents.",
|
||||||
onRetry: () => void documentsQuery.refetch(),
|
onRetry: () => void documentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1297,9 +1289,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
paymentsQuery.isError
|
paymentsQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load payments.",
|
message: "Failed to load payments.",
|
||||||
onRetry: () => void paymentsQuery.refetch(),
|
onRetry: () => void paymentsQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -1320,9 +1312,9 @@ export default function CustomerDetailPage() {
|
|||||||
error={
|
error={
|
||||||
invoicesQuery.isError
|
invoicesQuery.isError
|
||||||
? {
|
? {
|
||||||
message: "Failed to load invoices.",
|
message: "Failed to load invoices.",
|
||||||
onRetry: () => void invoicesQuery.refetch(),
|
onRetry: () => void invoicesQuery.refetch(),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
pagination={{
|
pagination={{
|
||||||
|
|||||||
Reference in New Issue
Block a user