MDA / frontend /src /components /Dashboard.tsx
Rom89823974978's picture
e
be1c0ed
raw
history blame
7.51 kB
import { useState, useEffect, useRef } from "react";
import {
Box,
Grid,
GridItem,
Text,
Flex,
Spinner,
SimpleGrid,
RangeSlider,
RangeSliderTrack,
RangeSliderFilledTrack,
RangeSliderThumb,
} from "@chakra-ui/react";
import Select from "react-select";
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
ArcElement,
LineElement,
PointElement,
RadialLinearScale,
} from "chart.js";
import { Bar, Pie, Doughnut, Line, Radar, PolarArea } from "react-chartjs-2";
import type { FilterState, AvailableFilters } from "../hooks/types";
// register chart components
ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
Title,
Tooltip,
Legend,
ArcElement,
LineElement,
PointElement,
RadialLinearScale
);
interface ChartData { labels: string[]; values: number[]; }
interface Stats { [key: string]: ChartData; }
const FILTER_LABELS: Record<keyof FilterState, string> = {
status: "Status",
organization: "Organization",
country: "Country",
legalBasis: "Legal Basis",
};
interface DashboardProps {
stats: Stats;
filters: FilterState;
setFilters: React.Dispatch<React.SetStateAction<FilterState>>;
availableFilters: AvailableFilters;
}
const chartTypes = ["bar","pie","doughnut","line","radar","polarArea"] as const;
type ChartType = typeof chartTypes[number];
const Dashboard: React.FC<DashboardProps> = ({
stats: initialStats,
filters,
setFilters,
availableFilters,
}) => {
const [orgInput, setOrgInput] = useState("");
const [statsData, setStatsData] = useState<Stats>(initialStats);
const [loadingStats, setLoadingStats] = useState(false);
const fetchTimer = useRef<number | null>(null);
// Debounced stats & filters fetch
useEffect(() => {
if (fetchTimer.current) clearTimeout(fetchTimer.current);
fetchTimer.current = window.setTimeout(() => {
const qs = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => v && qs.set(k, v));
setLoadingStats(true);
fetch(`/api/stats?${qs.toString()}`)
.then(r => r.json())
.then((data: Stats) => setStatsData(data))
.catch(console.error)
.finally(() => setLoadingStats(false));
}, 300);
return () => { if (fetchTimer.current) clearTimeout(fetchTimer.current); };
}, [filters]);
const updateFilter = (key: keyof FilterState) =>
(opt: { value: string } | null) =>
setFilters(prev => ({ ...prev, [key]: opt?.value || "" }));
const updateSlider = (
k1: 'minYear' | 'minFunding',
k2: 'maxYear' | 'maxFunding'
) => ([min, max]: number[]) =>
setFilters(prev => ({ ...prev, [k1]: String(min), [k2]: String(max) }));
const filterKeys: Array<keyof FilterState> = [
'status', 'organization', 'country', 'legalBasis'
];
if (loadingStats && !Object.keys(statsData).length) {
return <Flex justify="center" mt={10}><Spinner size="xl" /></Flex>;
}
return (
<Box>
{/* Filters */}
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="gray.50">
<Grid templateColumns={{ base: '1fr', sm: 'repeat(2,1fr)', md: 'repeat(4,1fr)', lg: 'repeat(6,1fr)' }} gap={4}>
{filterKeys.map(key => {
const opts = availableFilters[
key === 'status' ? 'statuses'
: key === 'organization' ? 'organizations'
: key === 'country' ? 'countries'
: 'legalBases'
] || [];
const isOrg = key === 'organization';
return (
<GridItem key={key} colSpan={1}>
<Text fontSize="sm" mb={1} fontWeight="medium">{FILTER_LABELS[key]}</Text>
<Select
options={opts.map(v => ({ label: v, value: v }))}
placeholder={FILTER_LABELS[key]}
onChange={updateFilter(key)}
isClearable
isSearchable
{...(isOrg && { menuIsOpen: orgInput.length>0, onInputChange: setOrgInput })}
/>
</GridItem>
);
})}
{/* Year Range */}
<GridItem colSpan={{ base: 1, md: 2 }}>
<Box mb={6}>
<Flex justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">Year Range</Text>
<Text fontSize="xs" color="gray.600">
{filters.minYear} – {filters.maxYear}
</Text>
</Flex>
<RangeSlider
aria-label={["Min Year","Max Year"]}
min={2000}
max={2025}
step={1}
defaultValue={[+filters.minYear, +filters.maxYear]}
onChange={updateSlider("minYear","maxYear")}
size="md"
>
<RangeSliderTrack>
<RangeSliderFilledTrack />
</RangeSliderTrack>
<RangeSliderThumb index={0} boxSize={4}/>
<RangeSliderThumb index={1} boxSize={4}/>
</RangeSlider>
</Box>
</GridItem>
{/* Funding Range */}
<GridItem colSpan={{ base: 1, md: 2 }}>
<Box>
<Flex justify="space-between" mb={1}>
<Text fontSize="sm" fontWeight="medium">Funding (€)</Text>
<Text fontSize="xs" color="gray.600">
€{Number(filters.minFunding).toLocaleString()} – €{Number(filters.maxFunding).toLocaleString()}
</Text>
</Flex>
<RangeSlider
aria-label={["Min Funding","Max Funding"]}
min={0}
max={1e7}
step={1e5}
defaultValue={[+filters.minFunding, +filters.maxFunding]}
onChange={updateSlider("minFunding","maxFunding")}
size="md"
>
<RangeSliderTrack>
<RangeSliderFilledTrack />
</RangeSliderTrack>
<RangeSliderThumb index={0} boxSize={4}/>
<RangeSliderThumb index={1} boxSize={4}/>
</RangeSlider>
</Box>
</GridItem>
</Grid>
</Box>
{/* Charts */}
{loadingStats && (
<Flex justify="center" mb={6}>
<Spinner />
</Flex>
)}
<SimpleGrid columns={{ base:1, md:2, lg:3 }} spacing={6}>
{Object.entries(statsData).map(([label, data], idx) => {
const type = chartTypes[idx % chartTypes.length] as ChartType;
const chartProps = {
data: { labels: data.labels, datasets: [{ label, data: data.values, backgroundColor: "#003399", borderColor: "#FFCC00", borderWidth: 1 }] },
options: { responsive: true, plugins: { legend: { position: "top" as const }, title: { display: true, text: label } } }
};
return (
<Box key={label} bg="white" borderRadius="md" p={4}>
{type === "bar" && <Bar {...chartProps} />}
{type === "pie" && <Pie {...chartProps} />}
{type === "doughnut" && <Doughnut {...chartProps} />}
{type === "line" && <Line {...chartProps} />}
{type === "radar" && <Radar {...chartProps} />}
{type === "polarArea" && <PolarArea {...chartProps} />}
</Box>
);
})}
</SimpleGrid>
</Box>
);
};
export default Dashboard;