mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
style: overview revamp
This commit is contained in:
@@ -40,6 +40,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import FinanceHubPage from "./pages/invoices/FinanceHubPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage";
|
||||
import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config";
|
||||
import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect";
|
||||
import ReportPage from "./pages/reports/ReportPage";
|
||||
import AuditLogsPage from "./pages/AuditLogsPage";
|
||||
@@ -222,6 +224,21 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* One drill-down route per overview domain — the old per-tab charts,
|
||||
now each on its own page. Single source of truth for the
|
||||
permission gate is OVERVIEW_DOMAINS, shared with the summary
|
||||
page's "View all" links. */}
|
||||
{OVERVIEW_DOMAINS.map((domain) => (
|
||||
<Route
|
||||
key={domain.key}
|
||||
path={`overview/${domain.key}`}
|
||||
element={
|
||||
<RequirePermission permission={domain.permission}>
|
||||
<OverviewDomainPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="reports"
|
||||
element={
|
||||
|
||||
@@ -36,6 +36,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
|
||||
subtitle: "Dashboard summary and key metrics",
|
||||
},
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/bookings",
|
||||
meta: { title: "Bookings", subtitle: "Booking volume, pipeline, and recent activity" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/contracts",
|
||||
meta: { title: "Contracts", subtitle: "Contract volume, pipeline, and recent activity" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/billing",
|
||||
meta: { title: "Billing", subtitle: "Revenue, payments, and collection status" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/operations",
|
||||
meta: { title: "Operations", subtitle: "Trains, schedules, containers, and cargo" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/fleet",
|
||||
meta: { title: "Fleet", subtitle: "Wagon and train fleet status" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/customers",
|
||||
meta: { title: "Customers", subtitle: "Customer growth and top accounts" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/overview/staff",
|
||||
meta: { title: "Staff", subtitle: "Employee and user account status" },
|
||||
},
|
||||
{
|
||||
prefix: "/dashboard/profile",
|
||||
meta: {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
Box,
|
||||
Clock,
|
||||
Container,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Train,
|
||||
Truck,
|
||||
UserCheck,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Group, Paper, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewKpis } from "@/types/overview";
|
||||
import { OverviewKpiCard } from "./OverviewKpiCard";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) {
|
||||
const bookingItems = [
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: kpis.bookings.totalActive,
|
||||
icon: FileText,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Needs action",
|
||||
value: kpis.bookings.needsAction,
|
||||
icon: AlertCircle,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: "Urgent",
|
||||
value: kpis.bookings.urgent,
|
||||
icon: Clock,
|
||||
accent: "rose" as const,
|
||||
},
|
||||
{
|
||||
label: "In approval",
|
||||
value: kpis.bookings.inApproval,
|
||||
icon: UserCheck,
|
||||
accent: "sky" as const,
|
||||
},
|
||||
{
|
||||
label: "Submitted today",
|
||||
value: kpis.bookings.submittedToday,
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const operationsItems = [
|
||||
{
|
||||
label: "Active trains",
|
||||
value: kpis.operations.trainsActive,
|
||||
icon: Train,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Wagons available",
|
||||
value: kpis.operations.wagonsAvailable,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Containers in transit",
|
||||
value: kpis.operations.containersInTransit,
|
||||
icon: Container,
|
||||
},
|
||||
{
|
||||
label: "Cargoes loaded",
|
||||
value: kpis.operations.cargoesLoaded,
|
||||
icon: Box,
|
||||
},
|
||||
];
|
||||
|
||||
const billingItems = [
|
||||
{
|
||||
label: "Revenue MTD (ETB)",
|
||||
value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"),
|
||||
icon: Banknote,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Revenue MTD (USD)",
|
||||
value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"),
|
||||
icon: Wallet,
|
||||
},
|
||||
{
|
||||
label: "Pending payments",
|
||||
value: kpis.billing.pendingPayments,
|
||||
icon: CreditCard,
|
||||
accent: "amber" as const,
|
||||
},
|
||||
{
|
||||
label: "Successful MTD",
|
||||
value: kpis.billing.successfulPaymentsMtd,
|
||||
icon: Banknote,
|
||||
},
|
||||
];
|
||||
|
||||
const peopleItems = [
|
||||
{
|
||||
label: "Total customers",
|
||||
value: kpis.customers.totalCustomers,
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
label: "New this month",
|
||||
value: kpis.customers.newCustomersThisMonth,
|
||||
icon: Users,
|
||||
accent: "emerald" as const,
|
||||
},
|
||||
{
|
||||
label: "Active employees",
|
||||
value: kpis.staff.activeEmployees,
|
||||
icon: UserCheck,
|
||||
},
|
||||
{
|
||||
label: "Active users",
|
||||
value: kpis.staff.activeUsers,
|
||||
icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
const sections = [
|
||||
{ title: "Bookings", items: bookingItems },
|
||||
{ title: "Operations", items: operationsItems },
|
||||
{ title: "Billing", items: billingItems },
|
||||
{ title: "Customers & staff", items: peopleItems },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{sections.map((section) => (
|
||||
<Paper
|
||||
key={section.title}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600} mb="sm" c="dimmed">
|
||||
{section.title}
|
||||
</Text>
|
||||
<Group gap="md" style={{ flexWrap: "nowrap", minWidth: "min-content" }}>
|
||||
{section.items.map((item) => (
|
||||
<OverviewKpiCard key={item.label} item={item} />
|
||||
))}
|
||||
</Group>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "30 days", value: "30d" },
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
interface OverviewPageHeaderProps {
|
||||
range: OverviewRange;
|
||||
onRangeChange: (range: OverviewRange) => void;
|
||||
generatedAt?: string;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewPageHeader({
|
||||
range,
|
||||
onRangeChange,
|
||||
generatedAt,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: OverviewPageHeaderProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Updated {formatRelativeTime(generatedAt)}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Paper, Stack, Table, Text } from "@mantine/core";
|
||||
import { History } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Table, Text } from "@mantine/core";
|
||||
|
||||
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
|
||||
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
||||
import type { IOverviewRecentBooking } from "@/types/overview";
|
||||
import { SummaryCard } from "./summary/SummaryCard";
|
||||
|
||||
function formatAmount(amount: number | null, currency: string | null) {
|
||||
if (amount == null) return "—";
|
||||
@@ -23,56 +25,90 @@ export function OverviewRecentBookingsTable({
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Paper p="lg" radius="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Text fw={600}>Recent bookings</Text>
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No recent bookings
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Customer</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Priority</Table.Th>
|
||||
<Table.Th>Amount</Table.Th>
|
||||
<Table.Th>Created</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((booking) => (
|
||||
<Table.Tr
|
||||
key={booking.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{booking.customerLabel}</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<SummaryCard
|
||||
icon={History}
|
||||
accent="gray"
|
||||
title="Recent bookings"
|
||||
subtitle="Latest submissions, newest first"
|
||||
minHeight={260}
|
||||
action={
|
||||
<Text
|
||||
component={Link}
|
||||
to="/dashboard/booking-requests"
|
||||
size="xs"
|
||||
fw={600}
|
||||
c="edr-green"
|
||||
style={{ whiteSpace: "nowrap", textDecoration: "none" }}
|
||||
>
|
||||
View all →
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
{bookings.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="lg">
|
||||
No recent bookings
|
||||
</Text>
|
||||
) : (
|
||||
<Table highlightOnHover verticalSpacing="sm" horizontalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{["Reference", "Customer", "Status", "Priority", "Amount", "Created"].map(
|
||||
(header) => (
|
||||
<Table.Th
|
||||
key={header}
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.3,
|
||||
color: "var(--mantine-color-edr-muted-6)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</Table.Th>
|
||||
),
|
||||
)}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{bookings.map((booking) => (
|
||||
<Table.Tr
|
||||
key={booking.id}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${booking.id}`)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" truncate style={{ maxWidth: 180 }}>
|
||||
{booking.customerLabel}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingStatusBadge status={booking.status} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<BookingPriorityBadge score={booking.priorityScore} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{formatAmount(booking.totalAmount, booking.paymentCurrency)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{new Date(booking.createdAt).toLocaleDateString()}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
import {
|
||||
useOverviewBillingTab,
|
||||
useOverviewBookingsTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
useOverviewStaffTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
|
||||
|
||||
function TabSkeleton() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={120} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewTabContentProps {
|
||||
tab: OverviewTabKey;
|
||||
range: OverviewRange;
|
||||
}
|
||||
|
||||
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
|
||||
const bookings = useOverviewBookingsTab(range, tab === "bookings");
|
||||
const contracts = useOverviewContractsTab(range, tab === "contracts");
|
||||
const billing = useOverviewBillingTab(range, tab === "billing");
|
||||
// Fleet reuses the operations dataset — same query key, so switching between
|
||||
// the two tabs costs one fetch.
|
||||
const operations = useOverviewOperationsTab(
|
||||
range,
|
||||
tab === "operations" || tab === "fleet",
|
||||
);
|
||||
const customers = useOverviewCustomersTab(range, tab === "customers");
|
||||
const staff = useOverviewStaffTab(range, tab === "staff");
|
||||
|
||||
const query =
|
||||
tab === "bookings"
|
||||
? bookings
|
||||
: tab === "contracts"
|
||||
? contracts
|
||||
: tab === "billing"
|
||||
? billing
|
||||
: tab === "operations" || tab === "fleet"
|
||||
? operations
|
||||
: tab === "customers"
|
||||
? customers
|
||||
: staff;
|
||||
|
||||
const { isLoading, isError, refetch, isFetching } = query;
|
||||
|
||||
if (isLoading) {
|
||||
return <TabSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !query.data) {
|
||||
return (
|
||||
<Paper p="xl" radius="lg" withBorder>
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Failed to load tab data"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Could not load {tab} metrics. Please try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md" pos="relative">
|
||||
{isFetching && (
|
||||
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{tab === "bookings" && bookings.data && (
|
||||
<OverviewBookingsTabPanel data={bookings.data} />
|
||||
)}
|
||||
{tab === "contracts" && contracts.data && (
|
||||
<OverviewContractsTabPanel data={contracts.data} />
|
||||
)}
|
||||
{tab === "billing" && billing.data && (
|
||||
<OverviewBillingTabPanel data={billing.data} />
|
||||
)}
|
||||
{tab === "operations" && operations.data && (
|
||||
<OverviewOperationsTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "fleet" && operations.data && (
|
||||
<OverviewFleetTabPanel data={operations.data} />
|
||||
)}
|
||||
{tab === "customers" && customers.data && (
|
||||
<OverviewCustomersTabPanel data={customers.data} />
|
||||
)}
|
||||
{tab === "staff" && staff.data && (
|
||||
<OverviewStaffTabPanel data={staff.data} />
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Banknote,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
TrainFront,
|
||||
UserCheck,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { FREIGHT_PERMS } from "@/lib/permissions";
|
||||
import type { OverviewTabKey } from "@/types/overview";
|
||||
|
||||
/**
|
||||
* Single source of truth for the seven overview drill-down pages — used both
|
||||
* to build the `/dashboard/overview/:domain` routes in App.tsx and to render
|
||||
* each page's header in OverviewDomainPage. One list, no duplicated permission
|
||||
* arrays to drift out of sync.
|
||||
*/
|
||||
export const OVERVIEW_DOMAINS: Array<{
|
||||
key: OverviewTabKey;
|
||||
label: string;
|
||||
subtitle: string;
|
||||
icon: LucideIcon;
|
||||
/** Any of these keys grants the page. */
|
||||
permission: string[];
|
||||
}> = [
|
||||
{
|
||||
key: "bookings",
|
||||
label: "Bookings",
|
||||
subtitle: "Booking volume, pipeline, and recent activity",
|
||||
icon: FileText,
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
key: "contracts",
|
||||
label: "Contracts",
|
||||
subtitle: "Contract volume, pipeline, and recent activity",
|
||||
icon: FileSignature,
|
||||
permission: [FREIGHT_PERMS.contracts.view],
|
||||
},
|
||||
{
|
||||
key: "billing",
|
||||
label: "Billing",
|
||||
subtitle: "Revenue, payments, and collection status",
|
||||
icon: Banknote,
|
||||
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
||||
},
|
||||
{
|
||||
key: "operations",
|
||||
label: "Operations",
|
||||
subtitle: "Trains, schedules, containers, and cargo",
|
||||
icon: Train,
|
||||
permission: [
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "fleet",
|
||||
label: "Fleet",
|
||||
subtitle: "Wagon and train fleet status",
|
||||
icon: TrainFront,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
|
||||
},
|
||||
{
|
||||
key: "customers",
|
||||
label: "Customers",
|
||||
subtitle: "Customer growth and top accounts",
|
||||
icon: Users,
|
||||
permission: [FREIGHT_PERMS.customers.view],
|
||||
},
|
||||
{
|
||||
key: "staff",
|
||||
label: "Staff",
|
||||
subtitle: "Employee and user account status",
|
||||
icon: UserCheck,
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,5 +1,8 @@
|
||||
/* ============================================================
|
||||
EDR Freight — Overview page styles (hero controls + tabs)
|
||||
EDR Freight — shared "premium" tab bar + segmented control styles.
|
||||
Named after the overview page they were first built for, but now shared
|
||||
by BookingStatusTabs, ContractStatusTabs, and ReceiveInventoryModal —
|
||||
do not remove `.ov-tablist` / `.ov-tab` without checking those importers.
|
||||
============================================================ */
|
||||
|
||||
/* ---- Hero range segmented control (on gradient) ---- */
|
||||
@@ -19,7 +22,7 @@
|
||||
color: var(--mantine-color-edr-green-7);
|
||||
}
|
||||
|
||||
/* ---- Premium tab bar ---- */
|
||||
/* ---- Premium tab bar (BookingStatusTabs, ContractStatusTabs, ReceiveInventoryModal) ---- */
|
||||
.ov-tablist {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
const DURATION_MS = 750;
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Animates a number from 0 to `value` (ease-out) on mount and whenever the
|
||||
* value changes — the hero-KPI count-up. Renders the final value immediately
|
||||
* when the user prefers reduced motion.
|
||||
*/
|
||||
export function CountUp({
|
||||
value,
|
||||
format = (n) => Math.round(n).toLocaleString(),
|
||||
}: {
|
||||
value: number;
|
||||
format?: (n: number) => string;
|
||||
}) {
|
||||
const [display, setDisplay] = useState(() => (prefersReducedMotion() ? value : 0));
|
||||
const frame = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion()) {
|
||||
setDisplay(value);
|
||||
return;
|
||||
}
|
||||
const start = performance.now();
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / DURATION_MS);
|
||||
const eased = 1 - (1 - t) ** 3;
|
||||
setDisplay(value * eased);
|
||||
if (t < 1) frame.current = requestAnimationFrame(tick);
|
||||
};
|
||||
frame.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(frame.current);
|
||||
}, [value]);
|
||||
|
||||
return <>{format(display)}</>;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Fragment } from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
|
||||
import type { IOverviewHeatmapCell } from "@/types/overview";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
/** ISO weekday order, 1 = Monday. */
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
/** 3-hour blocks, 0 = 00–03 … 7 = 21–24. */
|
||||
const BLOCK_LABELS = ["12a", "3a", "6a", "9a", "12p", "3p", "6p", "9p"];
|
||||
|
||||
/** Map an intensity in (0, 1] to the brand-green ramp; zero stays neutral. */
|
||||
function cellColor(count: number, max: number) {
|
||||
if (count === 0) return "var(--mantine-color-gray-1)";
|
||||
const shade = Math.max(1, Math.min(7, Math.ceil((count / max) * 7)));
|
||||
return `var(--mantine-color-edr-green-${shade})`;
|
||||
}
|
||||
|
||||
interface OverviewActivityHeatmapProps {
|
||||
cells: IOverviewHeatmapCell[];
|
||||
}
|
||||
|
||||
/**
|
||||
* When demand arrives: booking submissions by weekday × 3-hour block for the
|
||||
* selected range. The bright cells (and the peak badge) are the hours the
|
||||
* intake team needs to be staffed for.
|
||||
*/
|
||||
export function OverviewActivityHeatmap({ cells }: OverviewActivityHeatmapProps) {
|
||||
const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count]));
|
||||
const max = Math.max(0, ...cells.map((c) => c.count));
|
||||
const peak = cells.reduce<IOverviewHeatmapCell | null>(
|
||||
(best, c) => (c.count > (best?.count ?? 0) ? c : best),
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={CalendarClock}
|
||||
accent="violet"
|
||||
title="Booking rhythm"
|
||||
subtitle="When bookings arrive, by weekday and time"
|
||||
action={
|
||||
peak ? (
|
||||
<Badge variant="light" color="violet" size="sm" style={{ textTransform: "none" }}>
|
||||
Peak {DAY_LABELS[peak.dow - 1]} {BLOCK_LABELS[peak.block]}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{max === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No bookings in this period
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "36px repeat(8, 1fr)",
|
||||
gap: 3,
|
||||
}}
|
||||
>
|
||||
<span />
|
||||
{BLOCK_LABELS.map((label) => (
|
||||
<Text key={label} size="xs" c="dimmed" ta="center">
|
||||
{label}
|
||||
</Text>
|
||||
))}
|
||||
{DAY_LABELS.map((day, dayIndex) => (
|
||||
<Fragment key={day}>
|
||||
<Text size="xs" c="dimmed" style={{ lineHeight: "24px" }}>
|
||||
{day}
|
||||
</Text>
|
||||
{BLOCK_LABELS.map((_, block) => {
|
||||
const count = countByCell.get(`${dayIndex + 1}-${block}`) ?? 0;
|
||||
return (
|
||||
<Tooltip
|
||||
key={`${day}-${block}`}
|
||||
label={`${day} ${BLOCK_LABELS[block]}–${BLOCK_LABELS[(block + 1) % 8]} · ${count} booking${count === 1 ? "" : "s"}`}
|
||||
withArrow
|
||||
openDelay={150}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
background: cellColor(count, max),
|
||||
cursor: "default",
|
||||
transition: "transform 120ms ease",
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<Group gap={6} justify="flex-end" mt={4}>
|
||||
<Text size="xs" c="dimmed">
|
||||
Less
|
||||
</Text>
|
||||
{[0, 2, 4, 6].map((shade) => (
|
||||
<div
|
||||
key={shade}
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 4,
|
||||
background:
|
||||
shade === 0
|
||||
? "var(--mantine-color-gray-1)"
|
||||
: `var(--mantine-color-edr-green-${shade + 1})`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Text size="xs" c="dimmed">
|
||||
More
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
BellRing,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileSignature,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge, Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
|
||||
import type { IOverviewBillingKpis, IOverviewBookingKpis, IOverviewContractKpis } from "@/types/overview";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
interface OverviewAttentionCardProps {
|
||||
bookings: IOverviewBookingKpis;
|
||||
contracts: IOverviewContractKpis;
|
||||
billing: IOverviewBillingKpis;
|
||||
}
|
||||
|
||||
/**
|
||||
* The work queue, demoted below the money/volume story but still one glance
|
||||
* away — this page is read by executives first, staff second. Every row
|
||||
* links to the real filtered (or closest available) list; none are dead ends.
|
||||
*/
|
||||
export function OverviewAttentionCard({ bookings, contracts, billing }: OverviewAttentionCardProps) {
|
||||
const rows = [
|
||||
{
|
||||
key: "needsAction",
|
||||
label: "Bookings needing action",
|
||||
count: bookings.needsAction,
|
||||
icon: AlertCircle,
|
||||
href: "/dashboard/booking-requests",
|
||||
},
|
||||
{
|
||||
key: "urgent",
|
||||
label: "Urgent bookings",
|
||||
count: bookings.urgent,
|
||||
icon: Clock,
|
||||
href: "/dashboard/booking-requests",
|
||||
},
|
||||
{
|
||||
key: "contractsApproval",
|
||||
label: "Contracts in approval",
|
||||
count: contracts.inApproval,
|
||||
icon: FileSignature,
|
||||
href: "/dashboard/contract-requests",
|
||||
},
|
||||
{
|
||||
key: "contractsClearance",
|
||||
label: "Contracts in clearance",
|
||||
count: contracts.inClearance,
|
||||
icon: ShieldCheck,
|
||||
href: "/dashboard/contracts/clearance",
|
||||
},
|
||||
{
|
||||
key: "pendingPayments",
|
||||
label: "Pending payments",
|
||||
count: billing.pendingPayments,
|
||||
icon: Banknote,
|
||||
href: "/dashboard/payments",
|
||||
},
|
||||
];
|
||||
const openItems = rows.reduce((sum, row) => sum + row.count, 0);
|
||||
const allClear = openItems === 0;
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={BellRing}
|
||||
accent="orange"
|
||||
title="Needs attention"
|
||||
subtitle="Waiting on someone here"
|
||||
minHeight={260}
|
||||
action={
|
||||
allClear ? null : (
|
||||
<Badge variant="light" color="orange" size="sm" style={{ textTransform: "none" }}>
|
||||
{openItems.toLocaleString()} open
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
>
|
||||
{allClear ? (
|
||||
<Stack align="center" gap={8} py="lg">
|
||||
<ThemeIcon variant="light" color="edr-green" size={48} radius="xl">
|
||||
<Check size={26} />
|
||||
</ThemeIcon>
|
||||
<Text fw={700}>All clear</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing waiting on you right now.
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{rows.map((row) => {
|
||||
const Icon = row.icon;
|
||||
const active = row.count > 0;
|
||||
return (
|
||||
<Link
|
||||
key={row.key}
|
||||
to={row.href}
|
||||
className="ov-row"
|
||||
style={{ textDecoration: "none", color: "inherit", padding: "7px 10px" }}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={active ? "orange" : "gray"}
|
||||
size={30}
|
||||
radius={10}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="edr-text" truncate>
|
||||
{row.label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
ta="center"
|
||||
c={active ? "orange.8" : "dimmed"}
|
||||
style={{
|
||||
minWidth: 34,
|
||||
background: active
|
||||
? "var(--mantine-color-orange-0)"
|
||||
: "var(--mantine-color-gray-0)",
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{row.count}
|
||||
</Text>
|
||||
<ChevronRight size={14} style={{ color: "var(--mantine-color-edr-muted-6)" }} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "@/components/overview/overview.css";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "30 days", value: "30d" },
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
function formatRelativeTime(iso: string | undefined) {
|
||||
if (!iso) return "—";
|
||||
const diffMs = Date.now() - new Date(iso).getTime();
|
||||
const minutes = Math.floor(diffMs / 60_000);
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function greeting(hour: number) {
|
||||
if (hour < 12) return "Good morning";
|
||||
if (hour < 18) return "Good afternoon";
|
||||
return "Good evening";
|
||||
}
|
||||
|
||||
interface OverviewHeroProps {
|
||||
range: OverviewRange;
|
||||
onRangeChange: (range: OverviewRange) => void;
|
||||
generatedAt?: string;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand-gradient greeting banner: time-of-day greeting with the signed-in
|
||||
* user's first name, today's date, freshness badge, and the range controls.
|
||||
* Extra bottom padding leaves room for the KPI strip to overlap it.
|
||||
*/
|
||||
export function OverviewHero({
|
||||
range,
|
||||
onRangeChange,
|
||||
generatedAt,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
}: OverviewHeroProps) {
|
||||
const { user } = useAuth();
|
||||
const fullName = (user?.name?.en ?? user?.name?.am)?.trim();
|
||||
const firstName = fullName ? fullName.split(/\s+/)[0] : undefined;
|
||||
const now = new Date();
|
||||
const dateLabel = now.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: freightBrand.gradient,
|
||||
borderRadius: 20,
|
||||
padding: "28px 28px 76px",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Soft highlight so the flat gradient reads as a lit surface. */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"radial-gradient(640px 240px at 85% -40%, rgba(255, 255, 255, 0.2), transparent)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="wrap"
|
||||
gap="md"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Stack gap={6}>
|
||||
<Text c="white" fw={800} fz={26} lh={1.15} style={{ letterSpacing: "-0.02em" }}>
|
||||
{greeting(now.getHours())}
|
||||
{firstName ? `, ${firstName}` : ""} 👋
|
||||
</Text>
|
||||
<Group gap={10}>
|
||||
<Text c="rgba(255,255,255,0.75)" size="sm">
|
||||
{dateLabel}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
Updated {formatRelativeTime(generatedAt)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Group gap="sm">
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => onRangeChange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
classNames={{
|
||||
root: "ov-seg-root",
|
||||
indicator: "ov-seg-indicator",
|
||||
label: "ov-seg-label",
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="white"
|
||||
color="edr-green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh dashboard"
|
||||
onClick={onRefresh}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Banknote, FileSignature, FileText, Package } from "lucide-react";
|
||||
|
||||
import { KpiStrip, type KpiItem } from "@/components/page";
|
||||
import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview";
|
||||
import { CountUp } from "./CountUp";
|
||||
|
||||
function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */
|
||||
function pctDelta(current: number, previous: number): number | undefined {
|
||||
if (previous === 0) return undefined;
|
||||
return Math.round(((current - previous) / previous) * 100);
|
||||
}
|
||||
|
||||
interface OverviewHeroKpisProps {
|
||||
kpis: IOverviewKpis;
|
||||
current: IOverviewPeriodTotals;
|
||||
previous: IOverviewPeriodTotals;
|
||||
rangeLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The four numbers an executive reads first: money and volume for the
|
||||
* selected range, plus what's currently in flight. Revenue and cargo carry a
|
||||
* real vs-prior-period delta; the two workflow snapshots don't, because
|
||||
* "active bookings/contracts" is a point-in-time gauge, not a period total —
|
||||
* showing a delta for it would mean inventing a comparison that isn't real.
|
||||
*/
|
||||
export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) {
|
||||
const items: KpiItem[] = [
|
||||
{
|
||||
label: `Revenue (${rangeLabel})`,
|
||||
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
|
||||
hint: formatCurrency(current.revenueUsd, "USD"),
|
||||
icon: Banknote,
|
||||
color: "yellow",
|
||||
delta: pctDelta(current.revenueEtb, previous.revenueEtb),
|
||||
},
|
||||
{
|
||||
label: "Cargo moved",
|
||||
value: <CountUp value={current.tons} format={(n) => `${Math.round(n).toLocaleString()} t`} />,
|
||||
icon: Package,
|
||||
color: "edr-green",
|
||||
delta: pctDelta(current.tons, previous.tons),
|
||||
},
|
||||
{
|
||||
label: "Active bookings",
|
||||
value: <CountUp value={kpis.bookings.totalActive} />,
|
||||
icon: FileText,
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Active contracts",
|
||||
value: <CountUp value={kpis.contracts.totalActive} />,
|
||||
icon: FileSignature,
|
||||
color: "edr-green",
|
||||
},
|
||||
];
|
||||
|
||||
return <KpiStrip items={items} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
CalendarClock,
|
||||
Container as ContainerIcon,
|
||||
Send,
|
||||
Train,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { Group, SimpleGrid, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { MiniRing } from "@/components/common/MiniGraph";
|
||||
import type { IOverviewOperationsKpis } from "@/types/overview";
|
||||
import { CountUp } from "./CountUp";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
const STATS: Array<{
|
||||
key: keyof IOverviewOperationsKpis;
|
||||
label: string;
|
||||
icon: typeof Train;
|
||||
}> = [
|
||||
{ key: "trainsActive", label: "Trains active", icon: Train },
|
||||
{ key: "dispatchedToday", label: "Dispatched today", icon: Send },
|
||||
{ key: "schedulesUpcoming", label: "Upcoming departures", icon: CalendarClock },
|
||||
{ key: "containersInTransit", label: "Containers in transit", icon: ContainerIcon },
|
||||
];
|
||||
|
||||
/** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */
|
||||
export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) {
|
||||
const utilizationPct = kpis.wagonsTotal > 0 ? (kpis.wagonsAvailable / kpis.wagonsTotal) * 100 : null;
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={TrainFront}
|
||||
accent="edr-green"
|
||||
title="Network"
|
||||
subtitle="Fleet and movement, right now"
|
||||
to="/dashboard/overview/fleet"
|
||||
action={
|
||||
<Text size="xs" fw={600} c="edr-green" style={{ whiteSpace: "nowrap" }}>
|
||||
View fleet →
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Group align="center" gap="lg" className="ov-inset" p={14} wrap="nowrap">
|
||||
<MiniRing pct={utilizationPct} accent="emerald" size={76} stroke={8}>
|
||||
<Text size="16px" fw={800} lh={1}>
|
||||
{utilizationPct != null ? `${Math.round(utilizationPct)}%` : "—"}
|
||||
</Text>
|
||||
</MiniRing>
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.3 }}>
|
||||
Wagons available
|
||||
</Text>
|
||||
<Text fw={800} fz={22} lh={1.1}>
|
||||
{kpis.wagonsAvailable.toLocaleString()}
|
||||
<Text component="span" fz="sm" fw={600} c="dimmed">
|
||||
{" "}
|
||||
/ {kpis.wagonsTotal.toLocaleString()}
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
{STATS.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<Group key={stat.label} gap={10} wrap="nowrap" className="ov-inset" p={12}>
|
||||
<Icon
|
||||
size={18}
|
||||
style={{ flexShrink: 0, color: "var(--mantine-color-edr-green-6)" }}
|
||||
/>
|
||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||
<Text fw={800} fz="lg" lh={1.15}>
|
||||
<CountUp value={kpis[stat.key]} />
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{stat.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Filter } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config";
|
||||
import type { IOverviewPipelineCount } from "@/types/overview";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
/** Sequential green ramp from the theme's own edr-green scale — rising intensity as bookings progress through the pipeline. */
|
||||
const RAMP_SHADES = [3, 4, 5, 5, 6, 6, 7, 8, 9];
|
||||
|
||||
interface OverviewPipelineFunnelProps {
|
||||
data: IOverviewPipelineCount[];
|
||||
}
|
||||
|
||||
/** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */
|
||||
export function OverviewPipelineFunnel({ data }: OverviewPipelineFunnelProps) {
|
||||
const rows = data
|
||||
.map((item) => ({
|
||||
...item,
|
||||
tab: BOOKING_LIST_TABS.find((t) => t.key === item.stage),
|
||||
}))
|
||||
.filter((row) => row.tab);
|
||||
const maxCount = Math.max(1, ...rows.map((r) => r.count));
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0);
|
||||
const hasData = total > 0;
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={Filter}
|
||||
accent="edr-green"
|
||||
title="Booking pipeline"
|
||||
subtitle="Every row opens the exact filtered list"
|
||||
action={
|
||||
hasData ? (
|
||||
<Badge variant="light" color="edr-green" size="sm" style={{ textTransform: "none" }}>
|
||||
{total.toLocaleString()} in pipeline
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No bookings in pipeline
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={4}>
|
||||
{rows.map((row, index) => {
|
||||
const statuses = row.tab?.statuses;
|
||||
const href = statuses?.length
|
||||
? `/dashboard/booking-requests?statuses=${statuses.join(",")}`
|
||||
: "/dashboard/booking-requests";
|
||||
const shade = RAMP_SHADES[Math.min(index, RAMP_SHADES.length - 1)];
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={row.stage}
|
||||
to={href}
|
||||
className="ov-row"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
textDecoration: "none",
|
||||
padding: "7px 10px",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={500} c="edr-text" style={{ width: 140, flexShrink: 0 }} truncate>
|
||||
{row.tab?.label ?? row.stage}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 20,
|
||||
borderRadius: 999,
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${(row.count / maxCount) * 100}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background: `linear-gradient(90deg, var(--mantine-color-edr-green-${Math.max(shade - 2, 1)}), var(--mantine-color-edr-green-${shade}))`,
|
||||
transition: "width 300ms ease",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={700}
|
||||
ta="center"
|
||||
style={{
|
||||
minWidth: 44,
|
||||
flexShrink: 0,
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: 999,
|
||||
padding: "1px 8px",
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}}
|
||||
>
|
||||
{row.count}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { PieChart } from "lucide-react";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewRevenueSlice } from "@/types/overview";
|
||||
import { DIRECTION_COLORS, FLOW_FALLBACK_COLOR, FREIGHT_TYPE_COLORS } from "./flow-colors";
|
||||
import { CountUp } from "./CountUp";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
function formatEtb(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "ETB",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
const DIRECTION_LABELS: Record<string, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
DOMESTIC: "Domestic",
|
||||
};
|
||||
|
||||
const FREIGHT_TYPE_LABELS: Record<string, string> = {
|
||||
CONTAINER: "Container",
|
||||
BULK: "Bulk",
|
||||
};
|
||||
|
||||
/** One breakdown's proportion bar + legend, sized by ETB revenue (no FX rate exists to fold USD in). */
|
||||
function MixRow({
|
||||
title,
|
||||
slices,
|
||||
labels,
|
||||
colors,
|
||||
}: {
|
||||
title: string;
|
||||
slices: IOverviewRevenueSlice[];
|
||||
labels: Record<string, string>;
|
||||
colors: Record<string, string>;
|
||||
}) {
|
||||
const total = slices.reduce((sum, s) => sum + s.amountEtb, 0);
|
||||
|
||||
return (
|
||||
<Stack gap={8} className="ov-inset" p={12}>
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" style={{ letterSpacing: 0.3 }}>
|
||||
{title}
|
||||
</Text>
|
||||
{total === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No revenue in this period
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
overflow: "hidden",
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{slices
|
||||
.filter((s) => s.amountEtb > 0)
|
||||
.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
style={{
|
||||
width: `${(s.amountEtb / total) * 100}%`,
|
||||
background: colors[s.label] ?? FLOW_FALLBACK_COLOR,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Stack gap={4}>
|
||||
{slices
|
||||
.filter((s) => s.amountEtb > 0)
|
||||
.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 999,
|
||||
flexShrink: 0,
|
||||
background: colors[s.label] ?? FLOW_FALLBACK_COLOR,
|
||||
}}
|
||||
/>
|
||||
<span style={{ flex: 1, color: "var(--mantine-color-edr-muted-6)" }}>
|
||||
{labels[s.label] ?? s.label}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600 }}>{formatEtb(s.amountEtb)}</span>
|
||||
<span style={{ color: "var(--mantine-color-edr-muted-6)", minWidth: 32, textAlign: "right" }}>
|
||||
{Math.round((s.amountEtb / total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewRevenueMixProps {
|
||||
byDirection: IOverviewRevenueSlice[];
|
||||
byFreightType: IOverviewRevenueSlice[];
|
||||
}
|
||||
|
||||
/** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */
|
||||
export function OverviewRevenueMix({ byDirection, byFreightType }: OverviewRevenueMixProps) {
|
||||
const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0);
|
||||
|
||||
return (
|
||||
<SummaryCard icon={PieChart} accent="yellow" title="Revenue mix" subtitle="ETB, selected range">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Text fw={800} fz={24} lh={1.1} style={{ letterSpacing: "-0.02em" }}>
|
||||
<CountUp value={total} format={formatEtb} />
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
attributed to a trade direction
|
||||
</Text>
|
||||
</div>
|
||||
<MixRow
|
||||
title="By trade direction"
|
||||
slices={byDirection}
|
||||
labels={DIRECTION_LABELS}
|
||||
colors={DIRECTION_COLORS}
|
||||
/>
|
||||
<MixRow
|
||||
title="By freight type"
|
||||
slices={byFreightType}
|
||||
labels={FREIGHT_TYPE_LABELS}
|
||||
colors={FREIGHT_TYPE_COLORS}
|
||||
/>
|
||||
</Stack>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
Area,
|
||||
Bar,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
Line,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { ChartColumnBig } from "lucide-react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview";
|
||||
import { overviewChartColors } from "../overview.styles";
|
||||
import { chartAxisTick, chartGridStroke, chartTooltipStyle } from "./chart-style";
|
||||
import { mergeTrend } from "./mergeTrend";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
|
||||
function formatDateLabel(date: string) {
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatEtb(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "ETB",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
|
||||
|
||||
/** Dot-and-label legend row rendered in the card header instead of recharts' default. */
|
||||
function LegendDot({ color, dashed, label }: { color: string; dashed?: boolean; label: string }) {
|
||||
return (
|
||||
<Group gap={5} wrap="nowrap">
|
||||
{dashed ? (
|
||||
<svg width={14} height={4} aria-hidden>
|
||||
<line x1={0} y1={2} x2={14} y2={2} stroke={color} strokeWidth={2} strokeDasharray="3 2" />
|
||||
</svg>
|
||||
) : (
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: color }} />
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewRevenueVolumeChartProps {
|
||||
bookingTrend: IOverviewTrendPoint[];
|
||||
paymentTrend: IOverviewPaymentTrendPoint[];
|
||||
previousPaymentTrend: IOverviewPaymentTrendPoint[];
|
||||
rangeDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Volume and money in one read: bars are bookings created per day, the gold
|
||||
* area is ETB revenue per day, and the dashed ghost line is the preceding
|
||||
* period's revenue shifted onto the same axis — "are we pacing ahead of last
|
||||
* period" at a glance.
|
||||
*/
|
||||
export function OverviewRevenueVolumeChart({
|
||||
bookingTrend,
|
||||
paymentTrend,
|
||||
previousPaymentTrend,
|
||||
rangeDays,
|
||||
}: OverviewRevenueVolumeChartProps) {
|
||||
const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays);
|
||||
const hasData = data.some((point) => point.bookings > 0 || point.revenueEtb > 0);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={ChartColumnBig}
|
||||
accent="yellow"
|
||||
title="Bookings & revenue"
|
||||
subtitle="Daily volume vs ETB revenue"
|
||||
action={
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<LegendDot color="var(--mantine-color-edr-green-4)" label="Bookings" />
|
||||
<LegendDot color={overviewChartColors.primary} label="Revenue" />
|
||||
<LegendDot color="var(--mantine-color-gray-5)" dashed label="Prev period" />
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{!hasData ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No activity in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<ComposedChart data={data} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="ov-revenue-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={overviewChartColors.primary} stopOpacity={0.22} />
|
||||
<stop offset="100%" stopColor={overviewChartColors.primary} stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid vertical={false} stroke={chartGridStroke} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={chartAxisTick}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
minTickGap={24}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="bookings"
|
||||
allowDecimals={false}
|
||||
tick={chartAxisTick}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="revenue"
|
||||
orientation="right"
|
||||
tickFormatter={(value) => compact.format(Number(value))}
|
||||
tick={chartAxisTick}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={44}
|
||||
/>
|
||||
<Tooltip
|
||||
labelFormatter={(value) => formatDateLabel(String(value))}
|
||||
formatter={(value, name) =>
|
||||
name === "Bookings" ? [value, name] : [formatEtb(Number(value)), name]
|
||||
}
|
||||
contentStyle={chartTooltipStyle}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="bookings"
|
||||
dataKey="bookings"
|
||||
name="Bookings"
|
||||
fill="var(--mantine-color-edr-green-4)"
|
||||
radius={[5, 5, 0, 0]}
|
||||
barSize={14}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="prevRevenueEtb"
|
||||
name="Prev period"
|
||||
stroke="var(--mantine-color-gray-5)"
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="5 4"
|
||||
dot={false}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="revenue"
|
||||
type="monotone"
|
||||
dataKey="revenueEtb"
|
||||
name="Revenue"
|
||||
stroke={overviewChartColors.primary}
|
||||
strokeWidth={2.5}
|
||||
fill="url(#ov-revenue-fill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Waypoints } from "lucide-react";
|
||||
import { ResponsiveContainer, Sankey, Tooltip, type SankeyLinkProps } from "recharts";
|
||||
import { Text } from "@mantine/core";
|
||||
|
||||
import type { IOverviewRevenueFlow } from "@/types/overview";
|
||||
import { chartTooltipStyle } from "./chart-style";
|
||||
import { SummaryCard } from "./SummaryCard";
|
||||
import {
|
||||
DIRECTION_COLORS,
|
||||
FLOW_FALLBACK_COLOR,
|
||||
FLOW_LABELS,
|
||||
FREIGHT_TYPE_COLORS,
|
||||
} from "./flow-colors";
|
||||
|
||||
function formatEtb(amount: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "ETB",
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
interface SankeyNodeDatum {
|
||||
name: string;
|
||||
key: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Build recharts Sankey data: direction nodes on the left, freight types on the right. */
|
||||
function toSankeyData(flows: IOverviewRevenueFlow[]) {
|
||||
const active = flows.filter((f) => f.amountEtb > 0);
|
||||
const nodes: SankeyNodeDatum[] = [];
|
||||
const indexByKey = new Map<string, number>();
|
||||
const nodeIndex = (key: string, color: string) => {
|
||||
const existing = indexByKey.get(key);
|
||||
if (existing != null) return existing;
|
||||
nodes.push({ name: FLOW_LABELS[key] ?? key, key, color });
|
||||
indexByKey.set(key, nodes.length - 1);
|
||||
return nodes.length - 1;
|
||||
};
|
||||
|
||||
// Register directions first so they all land on the left column.
|
||||
for (const flow of active) {
|
||||
nodeIndex(flow.direction, DIRECTION_COLORS[flow.direction] ?? FLOW_FALLBACK_COLOR);
|
||||
}
|
||||
const links = active.map((flow) => ({
|
||||
source: indexByKey.get(flow.direction)!,
|
||||
target: nodeIndex(
|
||||
flow.freightType,
|
||||
FREIGHT_TYPE_COLORS[flow.freightType] ?? FLOW_FALLBACK_COLOR,
|
||||
),
|
||||
value: flow.amountEtb,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
function FlowNode({
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
index,
|
||||
payload,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
index: number;
|
||||
payload: { name?: string; value?: number; color?: string };
|
||||
}) {
|
||||
// Labels sit to the right of every bar: the right margin reserves room for
|
||||
// the last column, and the pale ribbons stay readable under the left one.
|
||||
return (
|
||||
<g key={`node-${index}`}>
|
||||
<rect x={x} y={y} width={width} height={height} fill={payload.color} rx={3} />
|
||||
<text
|
||||
x={x + width + 8}
|
||||
y={y + height / 2 - 6}
|
||||
textAnchor="start"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
fill="#1f2937"
|
||||
>
|
||||
{payload.name}
|
||||
</text>
|
||||
<text
|
||||
x={x + width + 8}
|
||||
y={y + height / 2 + 9}
|
||||
textAnchor="start"
|
||||
dominantBaseline="central"
|
||||
fontSize={11}
|
||||
fill="var(--mantine-color-gray-6)"
|
||||
>
|
||||
{formatEtb(payload.value ?? 0)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ribbon tinted by its source direction — the corridor keeps its color across the chart. */
|
||||
function FlowLink({
|
||||
sourceX,
|
||||
targetX,
|
||||
sourceY,
|
||||
targetY,
|
||||
sourceControlX,
|
||||
targetControlX,
|
||||
linkWidth,
|
||||
index,
|
||||
payload,
|
||||
}: SankeyLinkProps) {
|
||||
// Custom node fields (color) ride along on the layout node recharts hands back.
|
||||
const source = payload.source as { color?: string };
|
||||
return (
|
||||
<path
|
||||
key={`link-${index}`}
|
||||
d={`M${sourceX},${sourceY} C${sourceControlX},${sourceY} ${targetControlX},${targetY} ${targetX},${targetY}`}
|
||||
fill="none"
|
||||
stroke={source.color ?? FLOW_FALLBACK_COLOR}
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={linkWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewSankeyFlowProps {
|
||||
flows: IOverviewRevenueFlow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the money runs: ETB revenue as ribbons from trade direction to
|
||||
* freight type. Ribbon thickness is proportional to revenue, so the biggest
|
||||
* corridor is unmissable.
|
||||
*/
|
||||
export function OverviewSankeyFlow({ flows }: OverviewSankeyFlowProps) {
|
||||
const data = toSankeyData(flows);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={Waypoints}
|
||||
accent="sky"
|
||||
title="Revenue flow"
|
||||
subtitle="Trade direction → freight type, sized by ETB revenue"
|
||||
>
|
||||
{data.links.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No revenue in this period
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={252}>
|
||||
<Sankey
|
||||
data={data}
|
||||
node={FlowNode}
|
||||
link={FlowLink}
|
||||
nodePadding={32}
|
||||
margin={{ top: 20, right: 96, bottom: 12, left: 4 }}
|
||||
>
|
||||
<Tooltip
|
||||
formatter={(value) => formatEtb(Number(value))}
|
||||
contentStyle={chartTooltipStyle}
|
||||
/>
|
||||
</Sankey>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Group, Stack, Text, ThemeIcon } from "@mantine/core";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import "./overview-summary.css";
|
||||
|
||||
interface SummaryCardProps {
|
||||
icon: LucideIcon;
|
||||
/** Mantine color for the icon chip. */
|
||||
accent?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Right side of the header — a legend, badge, or link. */
|
||||
action?: ReactNode;
|
||||
/** Makes the whole card a link (adds the hover lift). */
|
||||
to?: string;
|
||||
minHeight?: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for every overview card: soft gradient surface, layered
|
||||
* shadow, icon-chip header with title/subtitle, optional action slot.
|
||||
* One look for the whole page instead of eight flat white boxes.
|
||||
*/
|
||||
export function SummaryCard({
|
||||
icon: Icon,
|
||||
accent = "edr-green",
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
to,
|
||||
minHeight = 340,
|
||||
children,
|
||||
}: SummaryCardProps) {
|
||||
const Root: ElementType = to ? Link : "div";
|
||||
return (
|
||||
<Root
|
||||
{...(to ? { to } : {})}
|
||||
className={to ? "ov-card ov-card--link" : "ov-card"}
|
||||
style={{ minHeight }}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md" gap="sm">
|
||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color={accent} size={38} radius={12} style={{ flexShrink: 0 }}>
|
||||
<Icon size={20} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text fw={700} lh={1.25} c="edr-text" truncate>
|
||||
{title}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Group>
|
||||
{action}
|
||||
</Group>
|
||||
{children}
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
/** Shared recharts styling for the overview summary charts. */
|
||||
export const chartGridStroke = "#EEF1F5";
|
||||
|
||||
export const chartAxisTick = { fontSize: 11, fill: "#8fa0b2" } as const;
|
||||
|
||||
export const chartTooltipStyle: CSSProperties = {
|
||||
borderRadius: 12,
|
||||
border: "1px solid #EEF1F5",
|
||||
boxShadow: "0 8px 24px rgba(16, 32, 47, 0.1)",
|
||||
fontSize: 12,
|
||||
padding: "8px 12px",
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Fixed colors + labels for trade directions and freight types, shared by the
|
||||
* revenue mix and Sankey so the same entity is always the same color (and
|
||||
* matches the operations departure chart's direction hexes).
|
||||
*/
|
||||
export const DIRECTION_COLORS: Record<string, string> = {
|
||||
EXPORT: "#D98A0B",
|
||||
IMPORT: "#0369a1",
|
||||
DOMESTIC: "#7c3aed",
|
||||
};
|
||||
|
||||
export const FREIGHT_TYPE_COLORS: Record<string, string> = {
|
||||
CONTAINER: "#1B9E7A",
|
||||
BULK: "#34D9AE",
|
||||
};
|
||||
|
||||
export const FLOW_LABELS: Record<string, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
DOMESTIC: "Domestic",
|
||||
CONTAINER: "Container",
|
||||
BULK: "Bulk",
|
||||
};
|
||||
|
||||
export const FLOW_FALLBACK_COLOR = "#94a3b8";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mergeTrend } from "./mergeTrend";
|
||||
|
||||
describe("mergeTrend", () => {
|
||||
it("unions dates from both trends, zero-filling the side that has no row", () => {
|
||||
const result = mergeTrend(
|
||||
[
|
||||
{ date: "2026-06-01", count: 3 },
|
||||
{ date: "2026-06-02", count: 5 },
|
||||
],
|
||||
[{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }],
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ date: "2026-06-01", bookings: 3, revenueEtb: 0 },
|
||||
{ date: "2026-06-02", bookings: 5, revenueEtb: 1000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("sorts chronologically regardless of input order", () => {
|
||||
const result = mergeTrend(
|
||||
[{ date: "2026-06-03", count: 1 }],
|
||||
[{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }],
|
||||
);
|
||||
|
||||
expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]);
|
||||
});
|
||||
|
||||
it("returns an empty series when both trends are empty", () => {
|
||||
expect(mergeTrend([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("shifts the previous period forward so day N lands on day N of the current window", () => {
|
||||
const result = mergeTrend(
|
||||
[{ date: "2026-06-08", count: 2 }],
|
||||
[],
|
||||
[
|
||||
// 7d range: 2026-06-01 + 7 = 2026-06-08 (existing row), 06-02 + 7 = 06-09 (new row)
|
||||
{ date: "2026-06-01", amountEtb: 400, amountUsd: 0 },
|
||||
{ date: "2026-06-02", amountEtb: 250, amountUsd: 0 },
|
||||
],
|
||||
7,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ date: "2026-06-08", bookings: 2, revenueEtb: 0, prevRevenueEtb: 400 },
|
||||
{ date: "2026-06-09", bookings: 0, revenueEtb: 0, prevRevenueEtb: 250 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("shifts across a month boundary without timezone drift", () => {
|
||||
const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7);
|
||||
expect(result[0].date).toBe("2026-06-04");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview";
|
||||
|
||||
export interface RevenueVolumePoint {
|
||||
date: string;
|
||||
bookings: number;
|
||||
revenueEtb: number;
|
||||
/** Same-offset day of the preceding period — the ghost comparison line. */
|
||||
prevRevenueEtb?: number;
|
||||
}
|
||||
|
||||
/** `date` (YYYY-MM-DD) plus `days` days, in UTC so no DST/timezone drift. */
|
||||
export function shiftDate(date: string, days: number): string {
|
||||
const parsed = new Date(`${date}T00:00:00Z`);
|
||||
parsed.setUTCDate(parsed.getUTCDate() + days);
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge the booking-count trend and the payment trend into one date-keyed
|
||||
* series for the combined volume/revenue chart. Both trends only carry rows
|
||||
* for days with activity (no zero-filled gaps), so this unions the dates
|
||||
* rather than assuming they line up.
|
||||
*
|
||||
* When the previous period's payment trend is provided, each of its days is
|
||||
* shifted forward by `shiftDays` (the range length) so day N of the prior
|
||||
* window lands on day N of the current one, and lands in `prevRevenueEtb`.
|
||||
*/
|
||||
export function mergeTrend(
|
||||
bookingTrend: IOverviewTrendPoint[],
|
||||
paymentTrend: IOverviewPaymentTrendPoint[],
|
||||
previousPaymentTrend: IOverviewPaymentTrendPoint[] = [],
|
||||
shiftDays = 0,
|
||||
): RevenueVolumePoint[] {
|
||||
const byDate = new Map<string, RevenueVolumePoint>();
|
||||
|
||||
for (const point of bookingTrend) {
|
||||
byDate.set(point.date, { date: point.date, bookings: point.count, revenueEtb: 0 });
|
||||
}
|
||||
for (const point of paymentTrend) {
|
||||
const existing = byDate.get(point.date);
|
||||
if (existing) {
|
||||
existing.revenueEtb = point.amountEtb;
|
||||
} else {
|
||||
byDate.set(point.date, { date: point.date, bookings: 0, revenueEtb: point.amountEtb });
|
||||
}
|
||||
}
|
||||
for (const point of previousPaymentTrend) {
|
||||
const date = shiftDate(point.date, shiftDays);
|
||||
const existing = byDate.get(date);
|
||||
if (existing) {
|
||||
existing.prevRevenueEtb = point.amountEtb;
|
||||
} else {
|
||||
byDate.set(date, {
|
||||
date,
|
||||
bookings: 0,
|
||||
revenueEtb: 0,
|
||||
prevRevenueEtb: point.amountEtb,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Staggered fade-up entrance for the overview bands. Delay is set inline per
|
||||
band; disabled entirely for reduced-motion users. */
|
||||
.ov-band {
|
||||
animation: ov-rise 420ms ease-out both;
|
||||
}
|
||||
|
||||
@keyframes ov-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ov-band {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Shared card chrome (SummaryCard) ---- */
|
||||
.ov-card {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfdfc 100%);
|
||||
border: 1px solid var(--mantine-color-edr-border-0);
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(16, 32, 47, 0.04),
|
||||
0 12px 32px -18px rgba(16, 32, 47, 0.14);
|
||||
transition:
|
||||
box-shadow 180ms ease,
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
}
|
||||
/* The lift is a click affordance — only linked cards get it. */
|
||||
.ov-card--link:hover {
|
||||
box-shadow:
|
||||
0 2px 4px rgba(16, 32, 47, 0.05),
|
||||
0 20px 44px -18px rgba(16, 32, 47, 0.2);
|
||||
border-color: var(--mantine-color-edr-green-2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Soft inset panel for grouping content inside a card. */
|
||||
.ov-inset {
|
||||
background: var(--mantine-color-gray-0);
|
||||
border: 1px solid var(--mantine-color-gray-1);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
/* Interactive list row inside a card. */
|
||||
.ov-row {
|
||||
border-radius: 12px;
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
.ov-row:hover {
|
||||
background: var(--mantine-color-gray-0);
|
||||
}
|
||||
@@ -59,12 +59,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps
|
||||
accent: "sky",
|
||||
hint: "Pending sign-off",
|
||||
},
|
||||
{
|
||||
label: "Submitted today",
|
||||
value: data.kpis.submittedToday,
|
||||
icon: FileText,
|
||||
hint: "New since midnight",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -76,12 +76,6 @@ export function OverviewContractsTabPanel({
|
||||
accent: "rose",
|
||||
hint: "Customs / documents",
|
||||
},
|
||||
{
|
||||
label: "Created today",
|
||||
value: data.kpis.createdToday,
|
||||
icon: FileSignature,
|
||||
hint: "New since midnight",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Box,
|
||||
CalendarClock,
|
||||
Container as ContainerIcon,
|
||||
Send,
|
||||
@@ -78,11 +77,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP
|
||||
value: data.kpis.containersInTransit,
|
||||
icon: ContainerIcon,
|
||||
},
|
||||
{
|
||||
label: "Cargoes loaded",
|
||||
value: data.kpis.cargoesLoaded,
|
||||
icon: Box,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -103,8 +103,16 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
component="span"
|
||||
fz="xs"
|
||||
fw={700}
|
||||
c={item.delta > 0 ? "edr-green" : "red"}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
c={item.delta > 0 ? "edr-green.7" : "red.7"}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
background:
|
||||
item.delta > 0
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-red-0)",
|
||||
borderRadius: 999,
|
||||
padding: "1px 7px",
|
||||
}}
|
||||
>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { ActionIcon, Alert, Button, Center, Loader, Paper, SegmentedControl, Skeleton, Stack } from "@mantine/core";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import { OVERVIEW_DOMAINS } from "@/components/overview/overview-domains.config";
|
||||
import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel";
|
||||
import { OverviewBookingsTabPanel } from "@/components/overview/tabs/OverviewBookingsTabPanel";
|
||||
import { OverviewContractsTabPanel } from "@/components/overview/tabs/OverviewContractsTabPanel";
|
||||
import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewFleetTabPanel } from "@/components/overview/tabs/OverviewFleetTabPanel";
|
||||
import { OverviewOperationsTabPanel } from "@/components/overview/tabs/OverviewOperationsTabPanel";
|
||||
import { OverviewStaffTabPanel } from "@/components/overview/tabs/OverviewStaffTabPanel";
|
||||
import {
|
||||
useOverviewBillingTab,
|
||||
useOverviewBookingsTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
useOverviewStaffTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
|
||||
const RANGE_OPTIONS = [
|
||||
{ label: "7 days", value: "7d" },
|
||||
{ label: "30 days", value: "30d" },
|
||||
{ label: "90 days", value: "90d" },
|
||||
];
|
||||
|
||||
function DomainSkeleton() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={92} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
<Skeleton height={320} radius="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One domain's full depth — what used to be a tab panel on the overview page
|
||||
* is now its own page, reached from that domain's "View all →" link. Same
|
||||
* per-domain hooks and panel components as before; only the tab-switch
|
||||
* wrapper (OverviewTabContent) is gone, replaced by a route param.
|
||||
*/
|
||||
export default function OverviewDomainPage() {
|
||||
const { domain } = useParams<{ domain: OverviewTabKey }>();
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
|
||||
const meta = OVERVIEW_DOMAINS.find((d) => d.key === domain);
|
||||
|
||||
const bookings = useOverviewBookingsTab(range, domain === "bookings");
|
||||
const contracts = useOverviewContractsTab(range, domain === "contracts");
|
||||
const billing = useOverviewBillingTab(range, domain === "billing");
|
||||
// Fleet reuses the operations dataset — same query key, so switching between
|
||||
// the two pages costs no extra fetch.
|
||||
const operations = useOverviewOperationsTab(
|
||||
range,
|
||||
domain === "operations" || domain === "fleet",
|
||||
);
|
||||
const customers = useOverviewCustomersTab(range, domain === "customers");
|
||||
const staff = useOverviewStaffTab(range, domain === "staff");
|
||||
|
||||
const query =
|
||||
domain === "bookings"
|
||||
? bookings
|
||||
: domain === "contracts"
|
||||
? contracts
|
||||
: domain === "billing"
|
||||
? billing
|
||||
: domain === "operations" || domain === "fleet"
|
||||
? operations
|
||||
: domain === "customers"
|
||||
? customers
|
||||
: staff;
|
||||
|
||||
const { isLoading, isError, refetch, isFetching } = query;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={meta?.label ?? "Overview"}
|
||||
subtitle={meta?.subtitle}
|
||||
backTo="/dashboard/overview"
|
||||
action={
|
||||
<>
|
||||
<SegmentedControl
|
||||
value={range}
|
||||
onChange={(value) => setRange(value as OverviewRange)}
|
||||
data={RANGE_OPTIONS}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="lg"
|
||||
radius="lg"
|
||||
aria-label="Refresh"
|
||||
onClick={() => void refetch()}
|
||||
loading={isFetching && !isLoading}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<DomainSkeleton />
|
||||
) : isError || !query.data ? (
|
||||
<Paper p="xl" radius="lg" withBorder>
|
||||
<Alert icon={<AlertCircle size={16} />} color="red" title="Failed to load" variant="light">
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Could not load {meta?.label ?? "this"} metrics. Please try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap="md" pos="relative">
|
||||
{isFetching && (
|
||||
<Center style={{ position: "absolute", top: 8, right: 8, zIndex: 2 }}>
|
||||
<Loader size="sm" color="edr-green" />
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{domain === "bookings" && bookings.data && <OverviewBookingsTabPanel data={bookings.data} />}
|
||||
{domain === "contracts" && contracts.data && <OverviewContractsTabPanel data={contracts.data} />}
|
||||
{domain === "billing" && billing.data && <OverviewBillingTabPanel data={billing.data} />}
|
||||
{domain === "operations" && operations.data && (
|
||||
<OverviewOperationsTabPanel data={operations.data} />
|
||||
)}
|
||||
{domain === "fleet" && operations.data && <OverviewFleetTabPanel data={operations.data} />}
|
||||
{domain === "customers" && customers.data && <OverviewCustomersTabPanel data={customers.data} />}
|
||||
{domain === "staff" && staff.data && <OverviewStaffTabPanel data={staff.data} />}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +1,61 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Banknote,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
TrainFront,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Container,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Tabs,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader";
|
||||
import { OverviewTabContent } from "@/components/overview/OverviewTabContent";
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
||||
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
||||
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useOverview } from "@/hooks/useOverview";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "@/components/overview/summary/overview-summary.css";
|
||||
|
||||
const TAB_ITEMS: Array<{
|
||||
value: OverviewTabKey;
|
||||
label: string;
|
||||
icon: typeof FileText;
|
||||
kpiKey:
|
||||
| "bookings"
|
||||
| "contracts"
|
||||
| "billing"
|
||||
| "operations"
|
||||
| "customers"
|
||||
| "staff";
|
||||
metricKey: string;
|
||||
/** Any of these keys grants the tab. */
|
||||
permission: string[];
|
||||
}> = [
|
||||
{
|
||||
value: "bookings",
|
||||
label: "Bookings",
|
||||
icon: FileText,
|
||||
kpiKey: "bookings",
|
||||
metricKey: "totalActive",
|
||||
permission: [FREIGHT_PERMS.bookings.view],
|
||||
},
|
||||
{
|
||||
value: "contracts",
|
||||
label: "Contracts",
|
||||
icon: FileSignature,
|
||||
kpiKey: "contracts",
|
||||
metricKey: "totalActive",
|
||||
permission: [FREIGHT_PERMS.contracts.view],
|
||||
},
|
||||
{
|
||||
value: "billing",
|
||||
label: "Billing",
|
||||
icon: Banknote,
|
||||
kpiKey: "billing",
|
||||
metricKey: "successfulPaymentsMtd",
|
||||
permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view],
|
||||
},
|
||||
{
|
||||
value: "operations",
|
||||
label: "Operations",
|
||||
icon: Train,
|
||||
kpiKey: "operations",
|
||||
metricKey: "trainsActive",
|
||||
permission: [
|
||||
FREIGHT_PERMS.trainScheduling.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
FREIGHT_PERMS.firstMile.view,
|
||||
FREIGHT_PERMS.lastMile.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
value: "fleet",
|
||||
label: "Fleet",
|
||||
icon: TrainFront,
|
||||
kpiKey: "operations",
|
||||
metricKey: "wagonsAvailable",
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view],
|
||||
},
|
||||
{
|
||||
value: "customers",
|
||||
label: "Customers",
|
||||
icon: Users,
|
||||
kpiKey: "customers",
|
||||
metricKey: "totalCustomers",
|
||||
permission: [FREIGHT_PERMS.customers.view],
|
||||
},
|
||||
{
|
||||
value: "staff",
|
||||
label: "Staff",
|
||||
icon: UserCheck,
|
||||
kpiKey: "staff",
|
||||
metricKey: "activeEmployees",
|
||||
permission: [
|
||||
FREIGHT_PERMS.admin,
|
||||
FREIGHT_PERMS.staff.roles.view,
|
||||
FREIGHT_PERMS.staff.employeeRegistration.view,
|
||||
FREIGHT_PERMS.staff.roleAssignment.view,
|
||||
],
|
||||
},
|
||||
];
|
||||
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
|
||||
const RANGE_DAYS: Record<OverviewRange, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
function HeaderSkeleton() {
|
||||
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Skeleton height={48} radius="md" />
|
||||
<Skeleton height={52} radius="lg" />
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||
function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Skeleton height={92} radius="lg" />
|
||||
<Skeleton height={340} radius="lg" />
|
||||
<Skeleton height={340} radius="lg" />
|
||||
<Skeleton height={340} radius="lg" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const OverviewPage = () => {
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
const [activeTab, setActiveTab] = useState<OverviewTabKey>("bookings");
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
||||
|
||||
// Permission-scoped view: only tabs the user may see; a restricted role
|
||||
// (e.g. operations) gets a summary 403 — that is not a connection problem.
|
||||
const visibleTabs = TAB_ITEMS.filter((tab) =>
|
||||
tab.permission.some((key) => hasPermission(user, key)),
|
||||
);
|
||||
const currentTab = visibleTabs.some((t) => t.value === activeTab)
|
||||
? activeTab
|
||||
: visibleTabs[0]?.value;
|
||||
const accessDenied =
|
||||
(error as { response?: { status?: number } } | null)?.response?.status === 403;
|
||||
|
||||
@@ -144,97 +64,113 @@ const OverviewPage = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT });
|
||||
};
|
||||
|
||||
const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => {
|
||||
if (!summary?.kpis) return 0;
|
||||
const group = summary.kpis[tab.kpiKey] as unknown as Record<string, number>;
|
||||
return group[tab.metricKey] ?? 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container fluid px="md" py="md">
|
||||
<Stack gap="lg">
|
||||
{isLoading && !summary ? (
|
||||
<HeaderSkeleton />
|
||||
) : (
|
||||
<OverviewPageHeader
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
generatedAt={summary?.generatedAt}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching && !isLoading}
|
||||
/>
|
||||
)}
|
||||
<PageContainer>
|
||||
{/* Gradient greeting hero; the KPI strip overlaps its bottom edge. */}
|
||||
<div className="ov-band">
|
||||
<OverviewHero
|
||||
range={range}
|
||||
onRangeChange={setRange}
|
||||
generatedAt={data?.generatedAt}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching && !isLoading}
|
||||
/>
|
||||
{data ? (
|
||||
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
|
||||
<OverviewHeroKpis
|
||||
kpis={data.kpis}
|
||||
current={data.current}
|
||||
previous={data.previous}
|
||||
rangeLabel={RANGE_LABEL[range]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isError && !accessDenied && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Unable to load dashboard summary"
|
||||
variant="light"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Check your connection and try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
{isError && !accessDenied && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Unable to load dashboard summary"
|
||||
variant="light"
|
||||
mt="lg"
|
||||
>
|
||||
<Stack gap="sm" align="flex-start">
|
||||
<span>Check your connection and try again.</span>
|
||||
<Button size="xs" variant="light" color="red" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{visibleTabs.length === 0 && !isLoading && !isError && (
|
||||
<Alert color="gray" variant="light" title="No dashboard sections available">
|
||||
Your role has no access to any overview section.
|
||||
</Alert>
|
||||
)}
|
||||
{accessDenied && (
|
||||
<Alert color="gray" variant="light" title="No dashboard access" mt="lg">
|
||||
Your role has no access to the overview.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{visibleTabs.length > 0 && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(value) =>
|
||||
setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value)
|
||||
}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
keepMounted={false}
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
{visibleTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = currentTab === tab.value;
|
||||
return (
|
||||
<Tabs.Tab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
leftSection={<Icon size={17} />}
|
||||
rightSection={
|
||||
summary ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={isActive ? "white" : "light"}
|
||||
color={isActive ? "edr-green" : "gray"}
|
||||
>
|
||||
{getTabBadge(tab)}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
})}
|
||||
</Tabs.List>
|
||||
{isLoading && !data ? (
|
||||
<Stack mt="lg">
|
||||
<OverviewSkeleton />
|
||||
</Stack>
|
||||
) : data ? (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range]}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{visibleTabs.map((tab) => (
|
||||
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
||||
<OverviewTabContent tab={tab.value} range={range} />
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||
<Band index={2} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 3 — the network now, and when demand arrives. */}
|
||||
<Band index={3} title="Network & rhythm">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||
<Band index={4} title="Pipeline">
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Band>
|
||||
</Stack>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user