From 8012b9f01ead404f3f25337cf27d5d62fc5a5f70 Mon Sep 17 00:00:00 2001
From: Bjarke Madsen <bjarke@nordu.net>
Date: Thu, 20 Apr 2023 14:31:31 +0200
Subject: [PATCH] Add staff graph page in frontend

---
 webapp/src/App.tsx                    |  15 ++-
 webapp/src/Schema.tsx                 |  28 ++++-
 webapp/src/helpers/dataconversion.tsx | 136 ++++++++++++++++++++-
 webapp/src/pages/CompendiumData.tsx   |   6 +
 webapp/src/pages/StaffGraph.tsx       | 170 ++++++++++++++++++++++++++
 5 files changed, 344 insertions(+), 11 deletions(-)
 create mode 100644 webapp/src/pages/StaffGraph.tsx

diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx
index 055bfaeb..ea11d0fe 100644
--- a/webapp/src/App.tsx
+++ b/webapp/src/App.tsx
@@ -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>
   );
 }
diff --git a/webapp/src/Schema.tsx b/webapp/src/Schema.tsx
index e6f7c4c3..4d3a935d 100644
--- a/webapp/src/Schema.tsx
+++ b/webapp/src/Schema.tsx
@@ -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
diff --git a/webapp/src/helpers/dataconversion.tsx b/webapp/src/helpers/dataconversion.tsx
index a045bdd6..6a20cc92 100644
--- a/webapp/src/helpers/dataconversion.tsx
+++ b/webapp/src/helpers/dataconversion.tsx
@@ -1,10 +1,9 @@
 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;
+}
diff --git a/webapp/src/pages/CompendiumData.tsx b/webapp/src/pages/CompendiumData.tsx
index fdb821bb..6f413b30 100644
--- a/webapp/src/pages/CompendiumData.tsx
+++ b/webapp/src/pages/CompendiumData.tsx
@@ -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>
diff --git a/webapp/src/pages/StaffGraph.tsx b/webapp/src/pages/StaffGraph.tsx
new file mode 100644
index 00000000..5bdabfe3
--- /dev/null
+++ b/webapp/src/pages/StaffGraph.tsx
@@ -0,0 +1,170 @@
+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;
-- 
GitLab