Skip to content
Snippets Groups Projects
Commit 8012b9f0 authored by Bjarke Madsen's avatar Bjarke Madsen
Browse files

Add staff graph page in frontend

parent bebd35b6
No related branches found
No related tags found
1 merge request!6Feature/comp 125 staffing graph
......@@ -8,6 +8,7 @@ import AnnualReport from "./pages/AnnualReport";
import CompendiumData from "./pages/CompendiumData";
import FundingSourcePage from "./pages/FundingSource";
import ChargingStructurePage from "./pages/ChargingStructure";
import StaffGraph from "./pages/StaffGraph";
import { FilterSelection } from "./Schema";
......@@ -21,21 +22,19 @@ function App(): ReactElement {
<ExternalPageNavBar />
<Routes>
<Route path="/data" element={<CompendiumData />} />
<Route path="/analysis" element={<DataAnalysis
filterSelection={filterSelection}
setFilterSelection={setFilterSelection}/>}
<Route path="/analysis" element={
<DataAnalysis filterSelection={filterSelection} setFilterSelection={setFilterSelection} />}
/>
<Route path="/report" element={<AnnualReport />} />
<Route path="/funding" element={<FundingSourcePage
filterSelection={filterSelection}
setFilterSelection={setFilterSelection}/>}
<Route path="/funding" element={<FundingSourcePage filterSelection={filterSelection} setFilterSelection={setFilterSelection} />}
/>
<Route path="/staff" element={<StaffGraph filterSelection={filterSelection} setFilterSelection={setFilterSelection} />} />
<Route path="/charging" element={<ChargingStructurePage />} />
<Route path="*" element={<Landing />} />
</Routes>
<GeantFooter/>
<GeantFooter />
</Router>
</div>
);
}
......
......@@ -55,7 +55,7 @@ export interface FundingSourceDataset {
}[]
}
export interface ChargingStructure{
export interface ChargingStructure {
NREN: string,
YEAR: number,
FEE_TYPE: (string | null),
......@@ -97,3 +97,29 @@ export interface Service {
url: string,
value: string
}
export interface NrenStaff {
nren: string,
year: number,
permanent_fte: number,
subcontracted_fte: number,
technical_fte: number,
non_technical_fte: number
}
export interface NrenStaffDataset {
labels: string[],
datasets: {
label: string,
data: number[],
backgroundColor: string
borderRadius: number,
borderSkipped: boolean,
barPercentage: number,
borderWidth: number,
categoryPercentage: number
stack: string
hidden: boolean
}[]
}
\ No newline at end of file
import { cartesianProduct } from 'cartesian-product-multiple-arrays';
import {
FundingSource,
FundingSourceDataset,
ChargingStructure,
ChargingStructureDataset, Budget, BasicDataset
ChargingStructureDataset, Budget, BasicDataset, NrenStaff, NrenStaffDataset
} from "../Schema";
const DEFAULT_CHARGING_STRUCTURE_DATA = [
......@@ -244,3 +243,136 @@ export function createChargingStructureDataset(chargingStructureData: ChargingSt
}
export const createNRENStaffDataset = (data: NrenStaff[], contractor: boolean) => {
function CreateDataLookup(data: NrenStaff[]) {
const dataLookup = new Map<string, Map<string, number>>();
data.forEach((item: NrenStaff) => {
const lookupKey = `${item.nren}/${item.year}`
let NrenStaffMap = dataLookup.get(lookupKey)
if (!NrenStaffMap) {
NrenStaffMap = new Map<string, number>();
}
const total = item.non_technical_fte + item.technical_fte
const technical_percentage = Math.round(((item.technical_fte / total) || 0) * 100)
const non_technical_percentage = Math.round(((item.non_technical_fte / total) || 0) * 100)
const contractor_total = item.subcontracted_fte + item.permanent_fte
const permanent_percentage = Math.round(((item.permanent_fte / contractor_total) || 0) * 100)
const subcontracted_percentage = Math.round(((item.subcontracted_fte / contractor_total) || 0) * 100)
NrenStaffMap.set("Technical FTE", technical_percentage)
NrenStaffMap.set("Non-technical FTE", non_technical_percentage)
NrenStaffMap.set("Permanent FTE", permanent_percentage)
NrenStaffMap.set("Subcontracted FTE", subcontracted_percentage)
dataLookup.set(lookupKey, NrenStaffMap)
})
return dataLookup
}
const dataLookup = CreateDataLookup(data)
const labelsYear = [...new Set(data.map((item: NrenStaff) => item.year))];
const labelsNREN = [...new Set(data.map((item: NrenStaff) => item.nren))];
let categories;
if (contractor) {
categories = [
"Permanent FTE",
"Subcontracted FTE"
]
} else {
categories = [
"Technical FTE",
"Non-technical FTE"
]
}
const categoriesPerYear = cartesianProduct(categories, labelsYear)
const nrenStaffDataset = categoriesPerYear.map(function ([category, year]) {
let color = ""
if (category === "Technical FTE") {
// color = dark blue
color = 'rgba(40, 40, 250, 0.8)'
} else if (category === "Permanent FTE") {
color = 'rgba(159, 129, 235, 1)'
} else if (category === "Subcontracted FTE") {
color = 'rgba(173, 216, 229, 1)'
}
else {
// light blue
color = 'rgba(116, 216, 242, 0.54)'
}
return {
backgroundColor: color,
label: `${category} (${year})`,
data: labelsNREN.map(nren => {
// ensure that the data is in the same order as the labels
const lookupKey = `${nren}/${year}`
const dataForYear = dataLookup.get(lookupKey)
if (!dataForYear) {
return 0
}
return dataForYear.get(category) ?? 0
}),
stack: year,
borderRadius: 10,
borderSkipped: true,
barPercentage: 0.8,
borderWidth: 0.5,
categoryPercentage: 0.8,
hidden: false
}
})
const dataset: NrenStaffDataset = {
datasets: nrenStaffDataset,
labels: labelsNREN.map(l => l.toString())
}
function sortDatasetByTechnicalFTE(dataset: NrenStaffDataset) {
// sort dataset by Technical FTE value descending and ensure labels are in the same order
const sortedDataset = { ...dataset }
const uniqueYears = [...new Set(dataset.datasets.map(d => d.stack))]
for (const year of uniqueYears) {
const technicalFTEIndex = dataset.datasets.findIndex(d => d.label === `Technical FTE (${year})` || d.label === `Permanent FTE (${year})`)
if (technicalFTEIndex === -1) {
continue
}
const nonTechnicalFTEIndex = dataset.datasets.findIndex(d => d.label === `Non-technical FTE (${year})` || d.label === `Subcontracted FTE (${year})`)
if (nonTechnicalFTEIndex === -1) {
continue
}
const datasetForYearTechnical = dataset.datasets[technicalFTEIndex];
const datasetForYearNonTechnical = dataset.datasets[nonTechnicalFTEIndex];
const dataByYear = datasetForYearTechnical.data.map((d, i) => ({
datatechnical: d,
datanontechnical: datasetForYearNonTechnical.data[i],
label: dataset.labels[i],
}))
function sortfunc(a: any, b: any) {
return b.datatechnical - a.datatechnical;
}
const sortedDataByYear = dataByYear.sort(sortfunc)
sortedDataset.datasets[technicalFTEIndex].data = sortedDataByYear.map(d => d.datatechnical)
sortedDataset.datasets[nonTechnicalFTEIndex].data = sortedDataByYear.map(d => d.datanontechnical)
sortedDataset.labels = sortedDataByYear.map(d => d.label)
}
return sortedDataset
}
const sortedDataset = sortDatasetByTechnicalFTE(dataset)
return sortedDataset;
}
......@@ -44,6 +44,12 @@ function CompendiumData(): ReactElement {
<span>Charging Mechanism of NRENs per Year</span>
</Link>
</Row>
<Row>
<Link to="/staff" className="link-text-underline">
<span>Staff count of NRENs per Year</span>
</Link>
</Row>
</div>
</CollapsibleBox>
</div>
......
import React, { useEffect, useMemo, useState } from 'react';
import { Col, Container, Row } from "react-bootstrap";
import { Bar } from 'react-chartjs-2';
import { createNRENStaffDataset } from "../helpers/dataconversion";
import Filter from "../components/graphing/Filter"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import {
NrenStaff,
FilterSelection
} from "../Schema";
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend
);
export const chartOptions = {
maintainAspectRatio: false,
animation: {
duration: 50
},
plugins: {
legend: {
display: true,
position: 'bottom' as const
},
tooltip: {
callbacks: {
label: function (tooltipItem) {
let label = tooltipItem.dataset.label || '';
if (tooltipItem.parsed.x !== null) {
label += `: ${tooltipItem.parsed.x}%`
}
return label;
}
},
},
},
scales: {
x: {
stacked: true,
ticks: {
callback: (value: string | number, index: number) => {
return `${index * 10}%`;
},
},
},
y: {
stacked: true,
ticks: {
autoSkip: false,
},
},
},
indexAxis: "y" as const
};
async function getData(): Promise<NrenStaff[]> {
try {
const response = await fetch('/api/staff/');
return response.json();
} catch (error) {
console.error(`Failed to load data: ${error}`);
throw error;
}
}
function getYearsAndNrens(sourceData: NrenStaff[]) {
const years = new Set<number>();
const nrens = new Set<string>();
sourceData.forEach(datapoint => {
years.add(datapoint.year);
nrens.add(datapoint.nren);
});
return { years: years, nrens: nrens };
}
interface inputProps {
filterSelection: FilterSelection
setFilterSelection: React.Dispatch<React.SetStateAction<FilterSelection>>
}
function StaffGraph({ filterSelection, setFilterSelection }: inputProps) {
const [staffData, setStaffData] = useState<NrenStaff[]>();
const [contractor, setContractor] = useState<boolean>(false);
const { years, nrens } = useMemo(
() => getYearsAndNrens(staffData || []),
[staffData]
);
const nrenStaffDataset = createNRENStaffDataset(staffData || [], contractor);
nrenStaffDataset.datasets.forEach(dataset => {
dataset.hidden = !filterSelection.selectedYears.includes(parseInt(dataset.stack));
});
// remove the datapoints and labels for the nrens that aren't selected
// unfortunately we cannot just hide them because graph.js doesn't want
// to create a stack from a single dataset
nrenStaffDataset.datasets.forEach(dataset => {
dataset.data = dataset.data.filter((e, i) => {
return filterSelection.selectedNrens.includes(nrenStaffDataset.labels[i]);
});
});
nrenStaffDataset.labels = nrenStaffDataset.labels.filter((e) => filterSelection.selectedNrens.includes(e));
useEffect(() => {
const loadData = async () => {
const staffData = await getData();
setStaffData(staffData);
// filter fallback for when nothing is selected (only last year for all nrens)
const { years, nrens } = getYearsAndNrens(staffData);
setFilterSelection(previous => {
const visibleYears = previous.selectedYears.filter(year => years.has(year));
const newSelectedYears = visibleYears.length ? previous.selectedYears : [Math.max(...years)];
const visibleNrens = previous.selectedNrens.filter(nren => nrens.has(nren));
const newSelectedNrens = visibleNrens.length ? previous.selectedNrens : [...nrens];
return { selectedYears: newSelectedYears, selectedNrens: newSelectedNrens };
});
}
loadData()
}, [setFilterSelection]);
return (
<Container>
<Row>
<Col xs={9}>
<Row>
<h3>NREN Staff</h3>
<input type="checkbox" id="contractor" name="contractor" value="contractor" onChange={() => setContractor(contractor => !contractor)} />
</Row>
<Row>
<div className="chart-container" style={{ 'minHeight': '60vh', 'width': '60vw', }}>
<Bar
data={nrenStaffDataset}
options={chartOptions}
/>
</div>
</Row>
</Col>
<Col xs={3}>
<Filter
filterOptions={{ availableYears: [...years], availableNrens: [...nrens] }}
filterSelection={filterSelection}
setFilterSelection={setFilterSelection}
/>
</Col>
</Row>
</Container>
);
}
export default StaffGraph;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment