Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/customers/CustomerDetailPage.tsx

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>
);
}