style: overview revamp

This commit is contained in:
Nathnael
2026-08-13 12:44:06 +00:00
parent 3190b9bd38
commit 0df4be1820
34 changed files with 2412 additions and 644 deletions

View File

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

View File

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