mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
1049 lines
32 KiB
TypeScript
1049 lines
32 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Anchor,
|
|
Badge,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Center,
|
|
Container,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
SimpleGrid,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
} from "@mantine/core";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Banknote,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
IdCard,
|
|
LayoutGrid,
|
|
Package,
|
|
Paperclip,
|
|
Receipt,
|
|
} from "lucide-react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useMemo } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
import {
|
|
BookingStatusBadge,
|
|
ChangeRequestPendingBadge,
|
|
ChangeRequestReview,
|
|
CompanyStatusBadge,
|
|
CompanyTypeBadge,
|
|
InvoiceStatusBadge,
|
|
PaymentStatusBadge,
|
|
ProfileApprovalActions,
|
|
ProfileChips,
|
|
ProfileStatusBadge,
|
|
ProfileTypeBadge,
|
|
ResetPasswordAction,
|
|
TableCard,
|
|
formatBytes,
|
|
formatDate,
|
|
formatMoney,
|
|
humanize,
|
|
} from "@/components/customers";
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import { fileViewUrl } from "@/constants/apiConfig";
|
|
import { api } from "@/services/api";
|
|
import type {
|
|
CompanyProfile,
|
|
CustomerBooking,
|
|
CustomerDocument,
|
|
CustomerPayment,
|
|
} from "@/types/customer";
|
|
import type { Invoice } from "@/types/invoice";
|
|
import {
|
|
DataTable,
|
|
useFileViewer,
|
|
usePagination,
|
|
type ColumnDef,
|
|
} from "@edr/ui-common";
|
|
|
|
function InfoField({ label, value }: { label: string; value?: string | null }) {
|
|
return (
|
|
<Stack gap={2}>
|
|
<Text
|
|
size="xs"
|
|
fw={600}
|
|
c="edr-muted"
|
|
tt="uppercase"
|
|
style={{ letterSpacing: "0.04em" }}
|
|
>
|
|
{label}
|
|
</Text>
|
|
<Text size="sm" c="edr-text">
|
|
{value && value.trim() ? value : "—"}
|
|
</Text>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
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";
|
|
/** A letter uploaded by an approved customer, awaiting this reviewer's approval. */
|
|
const POA_DELEGATION_PENDING_CODE = "poa_delegation_letter_pending";
|
|
|
|
export default function CustomerDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { view, viewer } = useFileViewer();
|
|
|
|
const { data: company, isLoading } = useQuery(
|
|
api.customers.getById.queryOptions({
|
|
input: { id: id ?? "" },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
const bookingsQuery = useQuery(
|
|
api.customers.bookings.queryOptions({
|
|
input: { id: id ?? "" },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
const documentsQuery = useQuery(
|
|
api.customers.documents.queryOptions({
|
|
input: { id: id ?? "" },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
const paymentsQuery = useQuery(
|
|
api.customers.payments.queryOptions({
|
|
input: { id: id ?? "" },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
|
|
const { pagination: invoicePagination, setPagination: setInvoicePagination } =
|
|
usePagination({
|
|
pageSize: 10,
|
|
});
|
|
const invoiceFilter = useMemo(
|
|
() => ({
|
|
companyId: id ?? "",
|
|
page: invoicePagination.pageIndex + 1,
|
|
pageSize: invoicePagination.pageSize,
|
|
}),
|
|
[id, invoicePagination.pageIndex, invoicePagination.pageSize],
|
|
);
|
|
const invoicesQuery = useQuery(
|
|
api.invoices.list.queryOptions({
|
|
input: { filter: invoiceFilter },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
|
|
const bookings = Array.isArray(bookingsQuery.data) ? bookingsQuery.data : [];
|
|
const documents = Array.isArray(documentsQuery.data)
|
|
? documentsQuery.data
|
|
: [];
|
|
const payments = Array.isArray(paymentsQuery.data) ? paymentsQuery.data : [];
|
|
const invoices = invoicesQuery.data?.items ?? [];
|
|
const invoiceTotal = invoicesQuery.data?.total ?? 0;
|
|
const invoicePageCount = Math.max(
|
|
1,
|
|
Math.ceil(invoiceTotal / invoicePagination.pageSize),
|
|
);
|
|
|
|
const totalPaid = useMemo(
|
|
() =>
|
|
payments
|
|
.filter((p) => p.status === "success")
|
|
.reduce((sum, p) => sum + p.amount, 0),
|
|
[payments],
|
|
);
|
|
const paidCurrency = payments[0]?.currency ?? "ETB";
|
|
|
|
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "type",
|
|
header: "Role",
|
|
cell: ({ row }) => (
|
|
<div className="space-y-2">
|
|
<ProfileTypeBadge type={row.original.type} />
|
|
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{row.original.reference}
|
|
</Text>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "licenseFiles",
|
|
header: "License documents",
|
|
cell: ({ row }) => {
|
|
const files = row.original.licenseFiles ?? [];
|
|
if (files.length === 0) {
|
|
return (
|
|
<Text size="sm" c="dimmed">
|
|
—
|
|
</Text>
|
|
);
|
|
}
|
|
return (
|
|
<Stack gap={4}>
|
|
{files.map((f) => (
|
|
<Group key={f.id} gap={6} wrap="nowrap">
|
|
<ActionIcon
|
|
size="sm"
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label={`View ${f.name}`}
|
|
onClick={() =>
|
|
view({
|
|
name: f.name,
|
|
url: fileViewUrl(f.id),
|
|
mimeType: f.mimeType,
|
|
})
|
|
}
|
|
>
|
|
<Eye size={14} />
|
|
</ActionIcon>
|
|
<Anchor
|
|
component="button"
|
|
type="button"
|
|
size="xs"
|
|
lineClamp={1}
|
|
onClick={() =>
|
|
view({
|
|
name: f.name,
|
|
url: fileViewUrl(f.id),
|
|
mimeType: f.mimeType,
|
|
})
|
|
}
|
|
style={{
|
|
maxWidth: 170,
|
|
textAlign: "left",
|
|
textDecoration:
|
|
f.status === "pending_remove"
|
|
? "line-through"
|
|
: undefined,
|
|
}}
|
|
>
|
|
{f.name}
|
|
</Anchor>
|
|
{f.status === "pending_add" && (
|
|
<Badge size="xs" color="yellow" variant="light">
|
|
Pending
|
|
</Badge>
|
|
)}
|
|
{f.status === "pending_remove" && (
|
|
<Badge size="xs" color="red" variant="light">
|
|
Removing
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
))}
|
|
</Stack>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => <ProfileStatusBadge status={row.original.status} />,
|
|
},
|
|
{
|
|
id: "createdAt",
|
|
header: "Registered",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{formatDate(row.original.createdAt)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<ProfileApprovalActions
|
|
profileId={row.original.id}
|
|
status={row.original.status}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
[view],
|
|
);
|
|
|
|
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "reference",
|
|
header: "Booking",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{row.original.reference}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "route",
|
|
header: "Route",
|
|
cell: ({ row }) => {
|
|
const b = row.original;
|
|
return (
|
|
<Group gap={6} wrap="nowrap">
|
|
<Text size="sm" c="edr-text">
|
|
{b.originLabel}
|
|
</Text>
|
|
<ArrowRight size={14} className="shrink-0 text-edr-muted" />
|
|
<Text size="sm" c="edr-text">
|
|
{b.destinationLabel}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: "type",
|
|
header: "Type",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{humanize(row.original.tradeDirection)} ·{" "}
|
|
{humanize(row.original.freightType)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => <BookingStatusBadge status={row.original.status} />,
|
|
},
|
|
{
|
|
id: "amount",
|
|
header: "Amount",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "createdAt",
|
|
header: "Created",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{formatDate(row.original.createdAt)}
|
|
</Text>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
const documentColumns: ColumnDef<CustomerDocument>[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "name",
|
|
header: "Document",
|
|
cell: ({ row }) => (
|
|
<Group gap="sm" wrap="nowrap">
|
|
<FileText size={16} className="shrink-0 text-edr-muted" />
|
|
<Text size="sm" c="edr-text" truncate>
|
|
{row.original.name}
|
|
</Text>
|
|
</Group>
|
|
),
|
|
},
|
|
{
|
|
id: "code",
|
|
header: "Type",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{humanize(row.original.code)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "size",
|
|
header: "Size",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{formatBytes(row.original.size)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "uploadedAt",
|
|
header: "Uploaded",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{formatDate(row.original.uploadedAt)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
|
<ActionIcon
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="View"
|
|
data-stop-row-click
|
|
onClick={() =>
|
|
view({
|
|
name: row.original.name,
|
|
url: fileViewUrl(row.original.id),
|
|
mimeType: row.original.mimeType,
|
|
})
|
|
}
|
|
>
|
|
<Eye size={16} />
|
|
</ActionIcon>
|
|
<ActionIcon
|
|
component="a"
|
|
href={fileViewUrl(row.original.id, true)}
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="Download"
|
|
data-stop-row-click
|
|
>
|
|
<Download size={16} />
|
|
</ActionIcon>
|
|
</Group>
|
|
),
|
|
},
|
|
],
|
|
[view],
|
|
);
|
|
|
|
const paymentColumns: ColumnDef<CustomerPayment>[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "reference",
|
|
header: "Payment",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{row.original.reference}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "booking",
|
|
header: "Booking",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{row.original.bookingReference}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "method",
|
|
header: "Method",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{humanize(row.original.method)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => <PaymentStatusBadge status={row.original.status} />,
|
|
},
|
|
{
|
|
id: "paidAt",
|
|
header: "Paid",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{formatDate(row.original.paidAt)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "amount",
|
|
header: "Amount",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{formatMoney(row.original.amount, row.original.currency)}
|
|
</Text>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
const invoiceColumns: ColumnDef<Invoice>[] = useMemo(
|
|
() => [
|
|
{
|
|
id: "invoiceNumber",
|
|
header: "Invoice",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{row.original.invoiceNumber}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "source",
|
|
header: "Source",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{humanize(row.original.source)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
|
},
|
|
{
|
|
id: "amount",
|
|
header: "Amount",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "dueAt",
|
|
header: "Due",
|
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{formatDate(row.original.dueAt)}
|
|
</Text>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
const licenseProfiles = (company?.companyProfiles ?? []).filter(
|
|
(p) => p.licenseFiles && p.licenseFiles.length > 0,
|
|
);
|
|
|
|
const poaDocuments = useMemo(
|
|
() =>
|
|
documents.filter(
|
|
(d) =>
|
|
d.code === POA_DELEGATION_CODE ||
|
|
d.code === POA_DELEGATION_PENDING_CODE,
|
|
),
|
|
[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());
|
|
// 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) && poaLive.length === 0;
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<Center mih="60vh">
|
|
<Loader />
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
if (!company) {
|
|
return (
|
|
<Container size="sm" py="xl">
|
|
<Stack align="center" gap="md">
|
|
<Text fw={700}>Customer not found</Text>
|
|
<Button
|
|
variant="default"
|
|
leftSection={<ArrowLeft size={16} />}
|
|
onClick={() => navigate("/dashboard/customers")}
|
|
>
|
|
Back to customers
|
|
</Button>
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageContainer>
|
|
<PageHeader
|
|
breadcrumbs={[
|
|
{ label: "Customers", href: "/dashboard/customers" },
|
|
{ label: company.name },
|
|
]}
|
|
backTo="/dashboard/customers"
|
|
title={company.name}
|
|
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
|
}`}
|
|
meta={
|
|
<Group gap="xs" wrap="nowrap">
|
|
<CompanyTypeBadge type={company.type} />
|
|
<CompanyStatusBadge status={company.status} />
|
|
<ChangeRequestPendingBadge companyId={company.id} />
|
|
</Group>
|
|
}
|
|
action={<ResetPasswordAction company={company} />}
|
|
/>
|
|
|
|
<Tabs defaultValue="overview">
|
|
<Tabs.List>
|
|
<Tabs.Tab value="overview" leftSection={<LayoutGrid size={16} />}>
|
|
Overview
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="bookings" leftSection={<Package size={16} />}>
|
|
Bookings
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="documents" leftSection={<FileText size={16} />}>
|
|
Documents
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="payments" leftSection={<Banknote size={16} />}>
|
|
Payments
|
|
</Tabs.Tab>
|
|
<Tabs.Tab value="invoices" leftSection={<Receipt size={16} />}>
|
|
Invoices
|
|
</Tabs.Tab>
|
|
</Tabs.List>
|
|
|
|
{/* OVERVIEW */}
|
|
<Tabs.Panel value="overview" pt="lg">
|
|
<Stack gap="lg">
|
|
<ChangeRequestReview company={company} />
|
|
|
|
<KpiStrip
|
|
items={[
|
|
{
|
|
label: "Profiles",
|
|
value: company.companyProfiles.length,
|
|
icon: IdCard,
|
|
color: "edr-green",
|
|
},
|
|
{
|
|
label: "Pending approval",
|
|
value: company.companyProfiles.filter(
|
|
(p) => p.status === "pending",
|
|
).length,
|
|
icon: IdCard,
|
|
color: "yellow",
|
|
},
|
|
{
|
|
label: "Bookings",
|
|
value: bookings.length,
|
|
icon: Package,
|
|
color: "blue",
|
|
},
|
|
{
|
|
label: "Total paid",
|
|
value: formatMoney(totalPaid, paidCurrency),
|
|
icon: Banknote,
|
|
color: "edr-green",
|
|
},
|
|
]}
|
|
/>
|
|
|
|
<Card>
|
|
<Stack gap="lg">
|
|
<Text fw={600} c="edr-text">
|
|
Company information
|
|
</Text>
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
|
<InfoField label="TIN" value={company.tin} />
|
|
<InfoField label="VAT number" value={company.vatNumber} />
|
|
<InfoField label="FAN number" value={company.fanNumber} />
|
|
<InfoField label="Country" value={company.country} />
|
|
<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="General manager"
|
|
value={company.generalManagerName}
|
|
/>
|
|
<InfoField
|
|
label="GM email"
|
|
value={company.generalManagerEmail}
|
|
/>
|
|
<InfoField
|
|
label="GM phone"
|
|
value={company.generalManagerPhone}
|
|
/>
|
|
</SimpleGrid>
|
|
</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>
|
|
) : poaLive.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>
|
|
{doc.code === POA_DELEGATION_PENDING_CODE && (
|
|
<Badge
|
|
size="xs"
|
|
color="yellow"
|
|
variant="light"
|
|
className="shrink-0"
|
|
>
|
|
Pending approval
|
|
</Badge>
|
|
)}
|
|
</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">
|
|
<Text fw={600} c="edr-text">
|
|
Role profiles
|
|
</Text>
|
|
<ProfileChips profiles={company.companyProfiles} />
|
|
</Group>
|
|
<Box style={{ overflowX: "auto" }} w="100%">
|
|
<Box miw={1040}>
|
|
<DataTable
|
|
columns={profileColumns}
|
|
data={company.companyProfiles}
|
|
status="success"
|
|
emptyMessage="No profiles registered."
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Stack>
|
|
</Card>
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
{/* BOOKINGS */}
|
|
<Tabs.Panel value="bookings" pt="lg">
|
|
<TableCard minWidth={900}>
|
|
<DataTable
|
|
columns={bookingColumns}
|
|
data={bookings}
|
|
status={tableStatus(bookingsQuery)}
|
|
emptyMessage="No bookings for this customer."
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
error={
|
|
bookingsQuery.isError
|
|
? {
|
|
message: "Failed to load bookings.",
|
|
onRetry: () => void bookingsQuery.refetch(),
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</TableCard>
|
|
</Tabs.Panel>
|
|
|
|
{/* DOCUMENTS */}
|
|
<Tabs.Panel value="documents" pt="lg">
|
|
<Stack gap="lg">
|
|
<TableCard minWidth={760}>
|
|
<DataTable
|
|
columns={documentColumns}
|
|
data={documents}
|
|
status={tableStatus(documentsQuery)}
|
|
emptyMessage="No documents uploaded."
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
error={
|
|
documentsQuery.isError
|
|
? {
|
|
message: "Failed to load documents.",
|
|
onRetry: () => void documentsQuery.refetch(),
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</TableCard>
|
|
|
|
{licenseProfiles.length > 0 && (
|
|
<Card>
|
|
<Stack gap="md">
|
|
<Text fw={600} c="edr-text">
|
|
Business licenses
|
|
</Text>
|
|
<Stack gap="md">
|
|
{licenseProfiles.map((p) => (
|
|
<Stack key={p.id} gap={6}>
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{humanize(p.type)} · {p.reference}
|
|
</Text>
|
|
{(p.licenseFiles ?? []).map((f) => (
|
|
<Group key={f.id} gap={8} wrap="nowrap">
|
|
<Paperclip size={13} className="text-edr-muted" />
|
|
<Anchor
|
|
component="button"
|
|
type="button"
|
|
onClick={() =>
|
|
view({
|
|
name: f.name,
|
|
url: fileViewUrl(f.id),
|
|
mimeType: f.mimeType,
|
|
})
|
|
}
|
|
size="xs"
|
|
style={{
|
|
textDecoration:
|
|
f.status === "pending_remove"
|
|
? "line-through"
|
|
: undefined,
|
|
}}
|
|
>
|
|
{f.name}
|
|
</Anchor>
|
|
{f.status === "pending_add" && (
|
|
<Badge size="xs" color="yellow" variant="light">
|
|
Pending approval
|
|
</Badge>
|
|
)}
|
|
{f.status === "pending_remove" && (
|
|
<Badge size="xs" color="red" variant="light">
|
|
Removal pending
|
|
</Badge>
|
|
)}
|
|
</Group>
|
|
))}
|
|
{(p.licenseFiles ?? []).length === 0 && (
|
|
<Text size="xs" c="dimmed">
|
|
No license documents.
|
|
</Text>
|
|
)}
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
</Stack>
|
|
</Card>
|
|
)}
|
|
</Stack>
|
|
</Tabs.Panel>
|
|
|
|
{/* PAYMENTS */}
|
|
<Tabs.Panel value="payments" pt="lg">
|
|
<TableCard minWidth={880}>
|
|
<DataTable
|
|
columns={paymentColumns}
|
|
data={payments}
|
|
status={tableStatus(paymentsQuery)}
|
|
emptyMessage="No payments recorded."
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
error={
|
|
paymentsQuery.isError
|
|
? {
|
|
message: "Failed to load payments.",
|
|
onRetry: () => void paymentsQuery.refetch(),
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
</TableCard>
|
|
</Tabs.Panel>
|
|
|
|
{/* INVOICES */}
|
|
<Tabs.Panel value="invoices" pt="lg">
|
|
<Box style={{ overflowX: "auto" }} w="100%">
|
|
<Box miw={860}>
|
|
<DataTable
|
|
columns={invoiceColumns}
|
|
data={invoices}
|
|
status={tableStatus(invoicesQuery)}
|
|
onRowClick={(row) => navigate(`/dashboard/invoices/${row.id}`)}
|
|
emptyMessage="No invoices for this customer."
|
|
containerClassName="border-0 shadow-none bg-transparent"
|
|
error={
|
|
invoicesQuery.isError
|
|
? {
|
|
message: "Failed to load invoices.",
|
|
onRetry: () => void invoicesQuery.refetch(),
|
|
}
|
|
: undefined
|
|
}
|
|
pagination={{
|
|
pageIndex: invoicePagination.pageIndex,
|
|
pageSize: invoicePagination.pageSize,
|
|
pageCount: invoicePageCount,
|
|
totalCount: invoiceTotal,
|
|
}}
|
|
tableOptions={{
|
|
state: { pagination: invoicePagination },
|
|
onPaginationChange: setInvoicePagination,
|
|
manualPagination: true,
|
|
pageCount: invoicePageCount,
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Tabs.Panel>
|
|
</Tabs>
|
|
|
|
{viewer}
|
|
</PageContainer>
|
|
);
|
|
}
|