mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
246 lines
6.8 KiB
TypeScript
246 lines
6.8 KiB
TypeScript
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 { 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 { 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";
|
|
|
|
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.fleet.view,
|
|
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,
|
|
],
|
|
},
|
|
];
|
|
|
|
function HeaderSkeleton() {
|
|
return (
|
|
<Stack gap="md">
|
|
<Skeleton height={48} radius="md" />
|
|
<Skeleton height={52} 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);
|
|
|
|
// 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;
|
|
|
|
const handleRefresh = () => {
|
|
void refetch();
|
|
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}
|
|
/>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{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>
|
|
|
|
{visibleTabs.map((tab) => (
|
|
<Tabs.Panel key={tab.value} value={tab.value} pt="lg">
|
|
<OverviewTabContent tab={tab.value} range={range} />
|
|
</Tabs.Panel>
|
|
))}
|
|
</Tabs>
|
|
)}
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default OverviewPage;
|