mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
603 lines
16 KiB
TypeScript
603 lines
16 KiB
TypeScript
import {
|
|
ActionIcon,
|
|
Box,
|
|
Button,
|
|
Card,
|
|
Center,
|
|
Container,
|
|
Group,
|
|
Loader,
|
|
SimpleGrid,
|
|
Stack,
|
|
Tabs,
|
|
Text,
|
|
} from "@mantine/core";
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Banknote,
|
|
Download,
|
|
FileText,
|
|
IdCard,
|
|
LayoutGrid,
|
|
Package,
|
|
} from "lucide-react";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { useMemo } from "react";
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
import {
|
|
BookingStatusBadge,
|
|
CompanyStatusBadge,
|
|
CompanyTypeBadge,
|
|
PaymentStatusBadge,
|
|
ProfileApprovalActions,
|
|
ProfileChips,
|
|
ProfileStatusBadge,
|
|
ProfileTypeBadge,
|
|
TableCard,
|
|
formatBytes,
|
|
formatDate,
|
|
formatMoney,
|
|
humanize,
|
|
} from "@/components/customers";
|
|
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
|
import { api } from "@/services/api";
|
|
import type {
|
|
CompanyProfile,
|
|
CustomerBooking,
|
|
CustomerDocument,
|
|
CustomerPayment,
|
|
} from "@/types/customer";
|
|
import { DataTable, 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";
|
|
}
|
|
|
|
export default function CustomerDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const { data: company, isLoading } = useQuery(
|
|
api.customers.getById.queryOptions({
|
|
input: { id: id ?? "" },
|
|
enabled: Boolean(id),
|
|
}),
|
|
);
|
|
const approveMutation = useMutation(
|
|
api.customers.setCompanyStatus.mutationOptions(),
|
|
);
|
|
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 bookings = bookingsQuery.data ?? [];
|
|
const documents = documentsQuery.data ?? [];
|
|
const payments = paymentsQuery.data ?? [];
|
|
|
|
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 }) => <ProfileTypeBadge type={row.original.type} />,
|
|
},
|
|
{
|
|
id: "reference",
|
|
header: "Reference",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" fw={600} c="edr-text">
|
|
{row.original.reference}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
id: "businessLicense",
|
|
header: "Business license",
|
|
cell: ({ row }) => (
|
|
<Text size="sm" c="dimmed">
|
|
{row.original.businessLicense || "—"}
|
|
</Text>
|
|
),
|
|
},
|
|
{
|
|
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}
|
|
/>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
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 }) => (
|
|
<ActionIcon
|
|
component="a"
|
|
href={row.original.url ?? "#"}
|
|
variant="subtle"
|
|
color="gray"
|
|
aria-label="Download"
|
|
data-stop-row-click
|
|
>
|
|
<Download size={16} />
|
|
</ActionIcon>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
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>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
);
|
|
|
|
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} />
|
|
{company.status === "pending" && (
|
|
<Button
|
|
size="xs"
|
|
color="green"
|
|
loading={approveMutation.isPending}
|
|
onClick={() =>
|
|
approveMutation.mutate({
|
|
companyId: company.id,
|
|
status: "active",
|
|
})
|
|
}
|
|
>
|
|
Approve
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
}
|
|
/>
|
|
|
|
<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.List>
|
|
|
|
{/* OVERVIEW */}
|
|
<Tabs.Panel value="overview" pt="lg">
|
|
<Stack gap="lg">
|
|
<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="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={860}>
|
|
<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">
|
|
<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>
|
|
</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>
|
|
</Tabs>
|
|
</PageContainer>
|
|
);
|
|
}
|