fea: add poa section to backoffice customer detail page

This commit is contained in:
Nathnael
2026-07-10 08:19:12 +00:00
parent 78cb1ac5d3
commit ea0f264d72
3 changed files with 177 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import {
Card,
Center,
Container,
Divider,
Group,
Loader,
SimpleGrid,
@@ -90,6 +91,9 @@ function tableStatus(query: { isLoading: boolean; isError: boolean }) {
return query.isLoading ? "loading" : query.isError ? "error" : "success";
}
/** Matches the fileKey seeded in the API's file-upload-settings seeder. */
const POA_DELEGATION_CODE = "poa_delegation_letter";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -531,6 +535,26 @@ export default function CustomerDetailPage() {
(p) => p.licenseFiles && p.licenseFiles.length > 0,
);
const poaDocuments = useMemo(
() => documents.filter((d) => d.code === POA_DELEGATION_CODE),
[documents],
);
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());
// A freight forwarder acts on other companies' behalf, so its PoA — details
// and delegation letter both — is mandatory rather than optional.
const poaMandatory = (company?.companyProfiles ?? []).some(
(p) => p.type === "freight_forwarder",
);
const delegationMissing =
(hasPoaDetails || poaMandatory) && poaDocuments.length === 0;
if (isLoading) {
return (
<Center mih="60vh">
@@ -672,6 +696,149 @@ export default function CustomerDetailPage() {
</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">
Delegation letter missing
</Badge>
) : poaDocuments.length > 0 ? (
<Badge size="sm" color="edr-green" variant="light">
Delegation letter 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}
/>
))}
</SimpleGrid>
) : (
<Text size="sm" c="dimmed">
No Power of Attorney representative recorded for this
customer.
</Text>
)}
<Divider />
<Stack gap="sm">
<Text
size="xs"
fw={600}
c="edr-muted"
tt="uppercase"
style={{ letterSpacing: "0.04em" }}
>
Delegation letter
</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 delegation letter 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={() =>
view({
name: doc.name,
url: fileViewUrl(doc.id),
mimeType: doc.mimeType,
})
}
>
{doc.name}
</Anchor>
<Text size="xs" c="dimmed" className="shrink-0">
{formatBytes(doc.size)} ·{" "}
{formatDate(doc.uploadedAt)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
aria-label={`Preview ${doc.name}`}
onClick={() =>
view({
name: doc.name,
url: fileViewUrl(doc.id),
mimeType: doc.mimeType,
})
}
>
<Eye size={16} />
</ActionIcon>
<ActionIcon
component="a"
href={fileViewUrl(doc.id, true)}
variant="subtle"
color="gray"
aria-label={`Download ${doc.name}`}
>
<Download size={16} />
</ActionIcon>
</Group>
</Group>
))
)}
</Stack>
</Stack>
</Card>
<Card>
<Stack gap="md">
<Group justify="space-between">

View File

@@ -33,6 +33,11 @@ function mapCompany(dto: Record<string, unknown>): Company {
generalManagerName: (attrs.generalManagerName as string | null) ?? null,
generalManagerEmail: (attrs.generalManagerEmail as string | null) ?? null,
generalManagerPhone: (attrs.generalManagerPhone as string | null) ?? null,
poaName: (attrs.poaName as string | null) ?? null,
poaEmail: (attrs.poaEmail as string | null) ?? null,
poaPhone: (attrs.poaPhone as string | null) ?? null,
poaLocation: (attrs.poaLocation as string | null) ?? null,
poaAddress: (attrs.poaAddress as string | null) ?? null,
};
}

View File

@@ -127,6 +127,11 @@ export interface Company {
generalManagerName?: string | null;
generalManagerEmail?: string | null;
generalManagerPhone?: string | null;
poaName?: string | null;
poaEmail?: string | null;
poaPhone?: string | null;
poaLocation?: string | null;
poaAddress?: string | null;
website?: string | null;
attributes?: Record<string, unknown> | null;
companyProfiles: CompanyProfile[];