diff --git a/modules/dashboard/test/DashboardTest.php b/modules/dashboard/test/DashboardTest.php
index 4a42da2ca7c..e3feae67c2b 100644
--- a/modules/dashboard/test/DashboardTest.php
+++ b/modules/dashboard/test/DashboardTest.php
@@ -358,30 +358,52 @@ public function testDashboardRecruitmentView()
$this->safeGet($this->url . '/dashboard/');
$views = $this->safeFindElement(
WebDriverBy::cssSelector(
- "#statistics_widgets .panel:nth-child(1) .views button"
+ "#statistics_widgets .panel:nth-child(2) .views button"
)
);
$views->click();
$assertText1 = $this->safeFindElement(
WebDriverBy::cssSelector(
- "#statistics_widgets .panel:nth-child(1)".
+ "#statistics_widgets .panel:nth-child(2)".
" .dropdown-menu li:nth-child(1)"
)
)->getText();
$assertText2 = $this->safeFindElement(
WebDriverBy::cssSelector(
- "#statistics_widgets .panel:nth-child(1)".
+ "#statistics_widgets .panel:nth-child(2)".
" .dropdown-menu li:nth-child(2)"
)
)->getText();
+ $assertText3 = $this->safeFindElement(
+ WebDriverBy::cssSelector(
+ "#statistics_widgets .panel:nth-child(2)".
+ " .dropdown-menu li:nth-child(3)"
+ )
+ )->getText();
+
+ $assertText4 = $this->safeFindElement(
+ WebDriverBy::cssSelector(
+ "#statistics_widgets .panel:nth-child(2)".
+ " .dropdown-menu li:nth-child(4)"
+ )
+ )->getText();
+
$this->assertStringContainsString("Recruitment - overall", $assertText1);
$this->assertStringContainsString(
"Recruitment - site breakdown",
$assertText2
);
+ $this->assertStringContainsString(
+ "Recruitment - project breakdown",
+ $assertText3
+ );
+ $this->assertStringContainsString(
+ "Recruitment - cohort breakdown",
+ $assertText4
+ );
}
/**
diff --git a/modules/statistics/css/WidgetIndex.css b/modules/statistics/css/WidgetIndex.css
new file mode 100644
index 00000000000..00a2306accc
--- /dev/null
+++ b/modules/statistics/css/WidgetIndex.css
@@ -0,0 +1,3 @@
+.c3-tooltip-container {
+ top: 0px !important;
+}
\ No newline at end of file
diff --git a/modules/statistics/jsx/WidgetIndex.js b/modules/statistics/jsx/WidgetIndex.js
index 389fd6d90ec..e87bb527936 100644
--- a/modules/statistics/jsx/WidgetIndex.js
+++ b/modules/statistics/jsx/WidgetIndex.js
@@ -4,10 +4,13 @@ import PropTypes from 'prop-types';
import Recruitment from './widgets/recruitment';
import StudyProgression from './widgets/studyprogression';
import {fetchData} from './Fetch';
-import {
- recruitmentCharts,
- studyProgressionCharts,
-} from './widgets/chartBuilder';
+import Modal from 'Modal';
+import Loader from 'Loader';
+import {SelectElement} from 'jsx/Form';
+
+import '../css/WidgetIndex.css';
+
+import {setupCharts} from './widgets/helpers/chartBuilder';
/**
* WidgetIndex - the main window.
@@ -18,6 +21,176 @@ import {
const WidgetIndex = (props) => {
const [recruitmentData, setRecruitmentData] = useState({});
const [studyProgressionData, setStudyProgressionData] = useState({});
+
+ const [modalChart, setModalChart] = useState(null);
+
+ // used by recruitment.js and studyprogression.js to display each chart.
+ const showChart = (section, chartID, chartDetails, setChartDetails) => {
+ let {sizing, title, chartType, options} = chartDetails[section][chartID];
+ return
+
+
+ {title}
+
+ {Object.keys(chartDetails[section][chartID].options).length > 1 &&
+ {
+ // set the chart type in the chartDetails object for that chartID
+ setChartDetails(
+ {...chartDetails,
+ [section]: {
+ ...chartDetails[section],
+ [chartID]: {
+ ...chartDetails[section][chartID],
+ chartType: options[value],
+ },
+ },
+ });
+ setupCharts(
+ false,
+ {[section]: {[chartID]: {
+ ...chartDetails[section][chartID],
+ chartType: options[value]},
+ }}
+ );
+ }}
+ />
+ }
+
+
{
+ setModalChart(chartDetails[section][chartID]);
+ setupCharts(
+ true,
+ {[section]: {[chartID]: chartDetails[section][chartID]}}
+ );
+ }}
+ id={chartID}
+ >
+
+
+
;
+ };
+
+ const downloadAsCSV = (data, filename, dataType) => {
+ const convertBarToCSV = (data) => {
+ const csvRows = [];
+
+ // Adding headers row
+ const headers = ['Labels', ...Object.keys(data.datasets)];
+ csvRows.push(headers.join(','));
+
+ // Adding data rows
+ const maxDatasetLength = Math.max(...Object.values(data.datasets).map(
+ (arr) => arr.length)
+ );
+ for (let i = 0; i < maxDatasetLength; i++) {
+ const values = [`"${data.labels[i]}"` || '']; // Label for this row
+ for (const datasetKey of Object.keys(data.datasets)) {
+ const value = data.datasets[datasetKey][i];
+ values.push(`"${value}"` || '');
+ }
+ csvRows.push(values.join(','));
+ }
+ return csvRows.join('\n');
+ };
+
+ const convertPieToCSV = (data) => {
+ const csvRows = [];
+ const headers = Object.keys(data[0]);
+ csvRows.push(headers.join(','));
+
+ for (const row of data) {
+ const values = headers.map((header) => {
+ const escapedValue = row[header].toString().replace(/"/g, '\\"');
+ return `"${escapedValue}"`;
+ });
+ csvRows.push(values.join(','));
+ }
+
+ return csvRows.join('\n');
+ };
+
+ const convertLineToCSV = (data) => {
+ const csvRows = [];
+
+ // Adding headers row
+ const headers = [
+ 'Labels',
+ ...data.datasets.map((dataset) => dataset.name),
+ ];
+ csvRows.push(headers.join(','));
+
+ // Adding data rows
+ for (let i = 0; i < data.labels.length; i++) {
+ const values = [data.labels[i]]; // Label for this row
+ for (const dataset of data.datasets) {
+ values.push(dataset.data[i] || '');
+ }
+ csvRows.push(values.join(','));
+ }
+
+ return csvRows.join('\n');
+ };
+
+ let csvData = '';
+ if (dataType == 'pie') {
+ csvData = convertPieToCSV(data);
+ } else if (dataType == 'bar') {
+ csvData = convertBarToCSV(data);
+ } else if (dataType == 'line') {
+ csvData = convertLineToCSV(data);
+ }
+ const blob = new Blob([csvData], {type: 'text/csv'});
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ };
+
+ // used by recruitment.js and studyprogression.js to update the filters for each chart.
+ const updateFilters = (
+ formDataObj,
+ section,
+ chartDetails,
+ setChartDetails
+ ) => {
+ let formObject = new FormData();
+ for (const key in formDataObj) {
+ if (formDataObj[key] != '' && formDataObj[key] != ['']) {
+ formObject.append(key, formDataObj[key]);
+ }
+ }
+ const queryString = '?' + new URLSearchParams(formObject).toString();
+
+ let newChartDetails = {...chartDetails};
+ Object.keys(chartDetails[section]).forEach((chart) => {
+ // update filters
+ let newChart = {...chartDetails[section][chart], filters: queryString};
+ setupCharts(false, {[section]: {[chart]: newChart}}).then((data) => {
+ // update chart data
+ newChartDetails[section][chart] = data[section][chart];
+ });
+ });
+ setChartDetails(newChartDetails);
+ };
+
/**
* Similar to componentDidMount and componentDidUpdate.
*/
@@ -33,9 +206,6 @@ const WidgetIndex = (props) => {
);
setRecruitmentData(data);
setStudyProgressionData(data);
- // setup statistics for c3.js charts.
- await studyProgressionCharts();
- await recruitmentCharts();
};
setup().catch((error) => {
console.error(error);
@@ -49,11 +219,80 @@ const WidgetIndex = (props) => {
*/
return (
<>
+ setModalChart(null)}
+ width={'1200px'}
+ title={modalChart && modalChart.title}
+ throwWarning={false}
+ >
+
+ {modalChart && modalChart.chartType &&
+ {
+ downloadAsCSV(
+ modalChart.data,
+ modalChart.title,
+ modalChart.dataType
+ );
+ }}
+ className='btn btn-info'>
+
+ {' '}Download data as csv
+
+ }
+ {modalChart
+ && modalChart.chartType
+ && modalChart.chartType !== 'line'
+ && {
+ exportChartAsImage('dashboardModal');
+ }}
+ className='btn btn-info'>
+
+ {' '}Download as image (png)
+
+ }
+
>
);
@@ -74,3 +313,63 @@ window.addEventListener('load', () => {
/>
);
});
+
+/**
+ * Helper function to export a chart as an image
+ *
+ * @param {string} chartId
+ */
+const exportChartAsImage = (chartId) => {
+ const chartContainer = document.getElementById(chartId);
+
+ if (!chartContainer) {
+ console.error(`Chart with ID '${chartId}' not found.`);
+ return;
+ }
+
+ // Get the SVG element that represents the chart
+ const svgNode = chartContainer.querySelector('svg');
+
+ // Clone the SVG node to avoid modifying the original chart
+ const clonedSvgNode = svgNode.cloneNode(true);
+
+ // Modify the font properties of the text elements
+ const textElements = clonedSvgNode.querySelectorAll('text');
+ textElements.forEach((textElement) => {
+ textElement.style.fontFamily = 'Arial, sans-serif';
+ textElement.style.fontSize = '12px';
+ });
+
+ // Create a canvas element
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+
+ // Get the SVG as XML data
+ const svgData = new XMLSerializer().serializeToString(clonedSvgNode);
+
+ // Create an image that can be used as the source for the canvas
+ const img = new Image();
+ img.onload = () => {
+ // Set the canvas size to match the chart's size
+ canvas.width = img.width;
+ canvas.height = img.height;
+
+ // Draw the image on the canvas
+ ctx.drawImage(img, 0, 0);
+
+ // Export the canvas to a data URL
+ const dataURL = canvas.toDataURL('image/png');
+
+ // Create a link and trigger a download
+ const link = document.createElement('a');
+ link.href = dataURL;
+ link.download = 'chart.png';
+ link.click();
+
+ // Clean up
+ canvas.remove();
+ };
+ img.src =
+ 'data:image/svg+xml;base64,'
+ + btoa(unescape(encodeURIComponent(svgData)));
+};
diff --git a/modules/statistics/jsx/widgets/chartBuilder.js b/modules/statistics/jsx/widgets/chartBuilder.js
deleted file mode 100644
index eb48d62dbd8..00000000000
--- a/modules/statistics/jsx/widgets/chartBuilder.js
+++ /dev/null
@@ -1,343 +0,0 @@
-import 'c3/c3.min.css';
-import c3 from 'c3';
-import {select} from 'd3';
-import {fetchData} from '../Fetch';
-
-const baseURL = window.location.origin;
-
-// Charts
-let scanLineChart;
-let recruitmentPieChart;
-let recruitmentBarChart;
-let recruitmentLineChart;
-
-// Colours for all charts broken down by only by site
-const siteColours = [
- '#F0CC00', '#27328C', '#2DC3D0', '#4AE8C2', '#D90074', '#7900DB', '#FF8000',
- '#0FB500', '#CC0000', '#DB9CFF', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2',
- '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5',
-];
-
-// Colours for the recruitment bar chart: breakdown by sex
-const sexColours = ['#2FA4E7', '#1C70B6'];
-
-/**
- * elementVisibility - used to resize charts when element becomes visible.
- *
- * @param {HTMLElement} element
- * @param {function} callback
- */
-const elementVisibility = (element, callback) => {
- const options = {
- root: document.documentElement,
- };
- const observer = new IntersectionObserver((entries, observer) => {
- entries.forEach((entry) => {
- callback(entry.intersectionRatio > 0);
- });
- }, options);
- observer.observe(element);
-};
-
-/**
- * formatPieData - used for the recruitment widget
- *
- * @param {object} data
- * @return {*[]}
- */
-const formatPieData = (data) => {
- const processedData = [];
- for (const [i] of Object.entries(data)) {
- const siteData = [data[i].label, data[i].total];
- processedData.push(siteData);
- }
- return processedData;
-};
-
-/**
- * formatBarData - used for the recruitment widget
- *
- * @param {object} data
- * @return {*[]}
- */
-const formatBarData = (data) => {
- const processedData = [];
- if (data['datasets']) {
- const females = ['Female'];
- processedData.push(females.concat(data['datasets']['female']));
- }
- if (data['datasets']) {
- const males = ['Male'];
- processedData.push(males.concat(data['datasets']['male']));
- }
- return processedData;
-};
-
-/**
- * formatLineData - used for the study progression widget
- *
- * @param {object} data
- * @return {*[]}
- */
-const formatLineData = (data) => {
- const processedData = [];
- const labels = [];
- labels.push('x');
- for (const [i] of Object.entries(data.labels)) {
- labels.push(data.labels[i]);
- }
- processedData.push(labels);
- for (const [i] of Object.entries(data['datasets'])) {
- const dataset = [];
- dataset.push(data['datasets'][i].name);
- processedData.push(dataset.concat(data['datasets'][i].data));
- }
- const totals = [];
- totals.push('Total');
- for (let j = 0; j < data['datasets'][0].data.length; j++) {
- let total = 0;
- for (let i = 0; i < data['datasets'].length; i++) {
- total += parseInt(data['datasets'][i].data[j]);
- }
- totals.push(total);
- }
- processedData.push(totals);
- return processedData;
-};
-
-/**
- * maxY - used for the study progression widget
- *
- * @param {object} data
- * @return {number}
- */
-const maxY = (data) => {
- let maxi = 0;
- for (let j = 0; j < data['datasets'][0].data.length; j++) {
- for (let i = 0; i < data['datasets'].length; i++) {
- maxi = Math.max(maxi, parseInt(data['datasets'][i].data[j]));
- }
- }
- return maxi;
-};
-
-/**
- * recruitmentCharts - fetch data for recruitments
- */
-const recruitmentCharts = async () => {
- // fetch data for the pie chart.
- let data = await fetchData(
- `${baseURL}/statistics/charts/siterecruitment_pie`,
- );
- const recruitmentPieData = formatPieData(data);
- recruitmentPieChart = c3.generate({
- bindto: '#recruitmentPieChart',
- size: {
- width: 227,
- },
- data: {
- columns: recruitmentPieData,
- type: 'pie',
- },
- color: {
- pattern: siteColours,
- },
- });
- elementVisibility(recruitmentPieChart.element, (visible) => {
- if (visible) {
- recruitmentPieChart.resize();
- }
- });
-
- // fetch data for the bar chart.
- data = await fetchData(
- `${baseURL}/statistics/charts/siterecruitment_bysex`,
- );
- const recruitmentBarData = formatBarData(data);
- const recruitmentBarLabels = data.labels;
- recruitmentBarChart = c3.generate({
- bindto: '#recruitmentBarChart',
- size: {
- width: 461,
- },
- data: {
- columns: recruitmentBarData,
- type: 'bar',
- },
- axis: {
- x: {
- type: 'categorized',
- categories: recruitmentBarLabels,
- },
- y: {
- label: 'Candidates registered',
- },
- },
- color: {
- pattern: sexColours,
- },
- });
- elementVisibility(recruitmentBarChart.element, (visible) => {
- if (visible) {
- recruitmentBarChart.resize();
- }
- });
-};
-
-/**
- * studyProgressionCharts - fetch data for study progression
- */
-const studyProgressionCharts = async () => {
- // fetch data for the line chart.
- let data = await fetchData(
- `${baseURL}/statistics/charts/scans_bymonth`,
- );
- let legendNames = [];
- for (let j = 0; j < data['datasets'].length; j++) {
- legendNames.push(data['datasets'][j].name);
- }
- const scanLineData = formatLineData(data);
- scanLineChart = c3.generate({
- size: {
- height: '100%',
- },
- bindto: '#scanChart',
- data: {
- x: 'x',
- xFormat: '%m-%Y',
- columns: scanLineData,
- type: 'area-spline',
- },
- legend: {
- show: false,
- },
- axis: {
- x: {
- type: 'timeseries',
- tick: {
- format: '%m-%Y',
- },
- },
- y: {
- max: maxY(data),
- label: 'Scans',
- },
- },
- zoom: {
- enabled: true,
- },
- color: {
- pattern: siteColours,
- },
- });
- select('.scanChartLegend')
- .insert('div', '.scanChart')
- .attr('class', 'legend')
- .selectAll('div').data(legendNames).enter()
- .append('div')
- .attr('data-id', function(id) {
- return id;
- })
- .html(function(id) {
- return '' + id;
- })
- .each(function(id) {
- select(this).select('span').style(
- 'background-color', scanLineChart.color(id),
- );
- })
- .on('mouseover', function(id) {
- scanLineChart.focus(id);
- })
- .on('mouseout', function(id) {
- scanLineChart.revert();
- })
- .on('click', function(id) {
- scanLineChart.toggle(id);
- });
- elementVisibility(scanLineChart.element, (visible) => {
- if (visible) {
- scanLineChart.resize();
- }
- });
- // scanLineChart.resize();
-
- // fetch data for the line chart.
- data = await fetchData(
- `${baseURL}/statistics/charts/siterecruitment_line`,
- );
- legendNames = [];
- for (let j = 0; j < data['datasets'].length; j++) {
- legendNames.push(data['datasets'][j].name);
- }
- const recruitmentLineData = formatLineData(data);
- recruitmentLineChart = c3.generate({
- size: {
- height: '100%',
- },
- bindto: '#recruitmentChart',
- data: {
- x: 'x',
- xFormat: '%m-%Y',
- columns: recruitmentLineData,
- type: 'area-spline',
- },
- legend: {
- show: false,
- },
- axis: {
- x: {
- type: 'timeseries',
- tick: {
- format: '%m-%Y',
- },
- },
- y: {
- max: maxY(data),
- label: 'Candidates registered',
- },
- },
- zoom: {
- enabled: true,
- },
- color: {
- pattern: siteColours,
- },
- });
- select('.recruitmentChartLegend')
- .insert('div', '.recruitmentChart')
- .attr('class', 'legend')
- .selectAll('div').data(legendNames).enter()
- .append('div')
- .attr('data-id', function(id) {
- return id;
- })
- .html(function(id) {
- return '' + id;
- })
- .each(function(id) {
- select(this).select('span').style(
- 'background-color',
- recruitmentLineChart.color(id));
- })
- .on('mouseover', function(id) {
- recruitmentLineChart.focus(id);
- })
- .on('mouseout', function(id) {
- recruitmentLineChart.revert();
- })
- .on('click', function(id) {
- recruitmentLineChart.toggle(id);
- });
- elementVisibility(recruitmentLineChart.element, (visible) => {
- if (visible) {
- recruitmentLineChart.resize();
- }
- });
- // recruitmentLineChart.resize();
-};
-
-export {
- recruitmentCharts,
- studyProgressionCharts,
-};
diff --git a/modules/statistics/jsx/widgets/helpers/chartBuilder.js b/modules/statistics/jsx/widgets/helpers/chartBuilder.js
new file mode 100644
index 00000000000..062e2f02847
--- /dev/null
+++ b/modules/statistics/jsx/widgets/helpers/chartBuilder.js
@@ -0,0 +1,371 @@
+/* eslint-disable */
+import 'c3/c3.min.css';
+import c3 from 'c3';
+import {fetchData} from '../../Fetch';
+
+const baseURL = window.location.origin;
+
+// Colours for all charts broken down by only by site
+const siteColours = [
+ '#F0CC00', '#27328C', '#2DC3D0', '#4AE8C2', '#D90074', '#7900DB', '#FF8000',
+ '#0FB500', '#CC0000', '#DB9CFF', '#8c564b', '#c49c94', '#e377c2', '#f7b6d2',
+ '#7f7f7f', '#c7c7c7', '#bcbd22', '#dbdb8d', '#17becf', '#9edae5',
+];
+
+// Colours for the recruitment bar chart: breakdown by sex
+const sexColours = ['#2FA4E7', '#1C70B6'];
+
+/**
+ * onload - override link click to cancel any fetch for statistical data.
+ */
+window.onload = () => {
+ document.body.addEventListener('click', (e) => {
+ // User clicks on a link..
+ if (
+ e.target &&
+ e.target.nodeName === 'A' &&
+ e.target.hasAttribute('data-target') === false
+ ) {
+ window.stop();
+ } else if (
+ e.target &&
+ e.target.nodeName === 'A' &&
+ e.target.hasAttribute('data-target') === true
+ ) {
+ const myTimeout = setTimeout(() => {
+ resizeGraphs();
+ clearTimeout(myTimeout);
+ }, 500);
+ }
+ });
+};
+
+let charts = []
+const resizeGraphs = () => {
+ charts.forEach((chart) => {
+ if (chart !== undefined) {
+ elementVisibility(chart.element, (visible) => {
+ if (visible) {
+ chart.resize();
+ }
+ })
+ }
+ })
+};
+
+/**
+ * elementVisibility - used to resize charts when element becomes visible.
+ * @param {HTMLElement} element
+ * @param {function} callback
+ */
+const elementVisibility = (element, callback) => {
+ const options = {
+ root: document.documentElement,
+ };
+ const observer = new IntersectionObserver((entries, observer) => {
+ entries.forEach((entry) => {
+ callback(entry.intersectionRatio > 0);
+ });
+ }, options);
+ observer.observe(element);
+};
+
+/**
+ * formatPieData - used for the recruitment widget
+ * @param {object} data
+ * @return {[]}
+ */
+const formatPieData = (data) => {
+ const processedData = [];
+ for (const [i] of Object.entries(data)) {
+ const siteData = [data[i].label, data[i].total];
+ processedData.push(siteData);
+ }
+ return processedData.filter((item) => item[1] > 0);
+};
+
+/**
+ * formatBarData - used for the recruitment widget
+ * @param {object} data
+ * @return {[]}
+ */
+const formatBarData = (data) => {
+ const processedData = [];
+ if (data['datasets']) {
+ const females = ['Female'];
+ processedData.push(females.concat(data['datasets']['female']));
+ }
+ if (data['datasets']) {
+ const males = ['Male'];
+ processedData.push(males.concat(data['datasets']['male']));
+ }
+ return processedData;
+};
+
+const createPieChart = (columns, id, targetModal, colours) => {
+ let newChart = c3.generate({
+ bindto: targetModal ? targetModal : id,
+ data: {
+ columns: columns,
+ type: 'pie',
+ },
+ size: {
+ height: targetModal ? 700 : 350,
+ width: targetModal ? 700 : 350,
+ },
+ color: {
+ pattern: colours,
+ },
+ pie: {
+ label: {
+ format: function(value, ratio, id) {
+ return value + "("+Math.round(100*ratio)+"%)";
+ }
+ }
+ },
+ });
+ charts.push(newChart);
+ resizeGraphs();
+}
+
+const createBarChart = (labels, columns, id, targetModal, colours, dataType) => {
+ let newChart = c3.generate({
+ bindto: targetModal ? targetModal : id,
+ data: {
+ x: dataType == 'pie' && 'x',
+ columns: columns,
+ type: 'bar',
+ colors: dataType === 'pie' ? {
+ [columns[1][0]]: function (d) {
+ return colours[d.index];
+ }
+ } :
+ {
+ [columns[0][0]]: colours[0],
+ [columns[1][0]]: colours[1],
+ }
+ },
+ size: {
+ width: targetModal ? 1000 : 350,
+ height: targetModal ? 700 : 350,
+ },
+ axis: {
+ x: {
+ type: 'category',
+ categories: labels,
+ },
+ y: {
+ label: {
+ text: 'Candidates registered',
+ position: 'inner-top'
+ },
+ },
+ },
+ color: {
+ pattern: colours,
+ },
+ legend: dataType === 'bar' ? {
+ position: 'inset',
+ inset: {
+ anchor: 'top-right',
+ x: 20,
+ y: 10,
+ step: 2
+ }
+ } : {
+ show: false
+ }
+ });
+ charts.push(newChart);
+ resizeGraphs();
+}
+
+const createLineChart = (data, columns, id, label, targetModal) => {
+ let newChart = c3.generate({
+ size: {
+ height: targetModal && 1000,
+ width: targetModal && 1000
+ },
+ bindto: targetModal ? targetModal : id,
+ data: {
+ x: 'x',
+ xFormat: '%m-%Y',
+ columns: columns,
+ type: 'area-spline',
+ },
+ legend: {
+ show: targetModal ? true : false,
+ },
+ axis: {
+ x: {
+ type: 'timeseries',
+ tick: {
+ format: '%m-%Y',
+ },
+ },
+ y: {
+ max: maxY(data),
+ label: label,
+ },
+ },
+ zoom: {
+ enabled: true,
+ },
+ color: {
+ pattern: siteColours,
+ },
+ tooltip: {
+ // hide if 0
+ contents: function (d, defaultTitleFormat, defaultValueFormat, color) {
+ let $$ = this,
+ config = $$.config,
+ titleFormat = config.tooltip_format_title || defaultTitleFormat,
+ nameFormat = config.tooltip_format_name || function (name) { return name; },
+ valueFormat = config.tooltip_format_value || defaultValueFormat,
+ text, i, title, value, name, bgcolor;
+ for (i = 0; i < d.length; i++) {
+ if (d[i] && d[i].value == 0) { continue; }
+
+ if (! text) {
+ title = titleFormat ? titleFormat(d[i].x) : d[i].x;
+ text = "";
+ }
+
+ }
+ });
+ charts.push(newChart);
+ resizeGraphs();
+}
+
+const getChartData = async (target, filters) => {
+ let query = `${baseURL}/statistics/charts/${target}`
+ if (filters) {
+ query = query + filters;
+ }
+ return await fetchData(query);
+}
+
+/**
+ * setupCharts - fetch data for charts
+ * If data is provided, use that instead of fetching
+ * There are three types of data provided. Pie, bar and line
+ * This is determined by the original chart type of the data provided from the API
+ * If data was provided as a Pie, and the requested chartType is Bar, then the data will be reformatted
+ */
+const setupCharts = async (targetIsModal, chartDetails) => {
+ const chartPromises = [];
+ let newChartDetails = {...chartDetails}
+ Object.keys(chartDetails).forEach((section) => {
+ Object.keys(chartDetails[section]).forEach((chartID) => {
+ let chart = chartDetails[section][chartID];
+ let data = chart.data;
+ const chartPromise = (data && !chart.filters ? Promise.resolve(data) : getChartData(chartID, chart.filters))
+ .then((chartData) => {
+ let columns = {};
+ let labels = [];
+ let colours = [];
+ if (chart.dataType === 'pie') {
+ columns = formatPieData(chartData);
+ colours = siteColours;
+ // reformating the columns for a bar chart when it was originally pie data
+ if (chart.chartType == 'bar') {
+ let newColumns = [['x'], [chart.label]];
+ columns.forEach((column, index) => {
+ newColumns[0].push(column[0]);
+ newColumns[1].push(column[1]);
+ labels.push(column[0]);
+ });
+ columns = newColumns;
+ }
+ } else if (chart.dataType === 'bar') {
+ columns = formatBarData(chartData);
+ labels = chartData.labels;
+ colours = sexColours;
+ } else if (chart.dataType === 'line') {
+ columns = formatLineData(chartData);
+ if (chart.chartType !== 'line') {
+ // remove first and last (x and total)
+ columns = columns.slice(1, columns.length - 1);
+ }
+ }
+ if (chart.chartType === 'pie') {
+ createPieChart(columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours);
+ } else if (chart.chartType === 'bar') {
+ createBarChart(labels, columns, `#${chartID}`, targetIsModal && '#dashboardModal', colours, chart.dataType);
+ } else if (chart.chartType === 'line') {
+ createLineChart(chartData, columns, `#${chartID}`, chart.label, targetIsModal && '#dashboardModal');
+ }
+ newChartDetails[section][chartID].data = chartData;
+ });
+
+ chartPromises.push(chartPromise);
+ });
+ });
+
+ await Promise.all(chartPromises);
+ return newChartDetails;
+};
+
+/**
+ * formatLineData - used for the study progression widget
+ * @param {object} data
+ * @return {*[]}
+ */
+const formatLineData = (data) => {
+ const processedData = [];
+ const labels = [];
+ labels.push('x');
+ for (const [i] of Object.entries(data.labels)) {
+ labels.push(data.labels[i]);
+ }
+ processedData.push(labels);
+ for (const [i] of Object.entries(data['datasets'])) {
+ const dataset = [];
+ dataset.push(data['datasets'][i].name);
+ processedData.push(dataset.concat(data['datasets'][i].data));
+ }
+ const totals = [];
+ totals.push('Total');
+ for (let j = 0; j < data['datasets'][0].data.length; j++) {
+ let total = 0;
+ for (let i = 0; i < data['datasets'].length; i++) {
+ total += parseInt(data['datasets'][i].data[j]);
+ }
+ totals.push(total);
+ }
+ processedData.push(totals);
+ return processedData;
+};
+
+/**
+ * maxY - used for the study progression widget
+ * @param {object} data
+ * @return {number}
+ */
+const maxY = (data) => {
+ let maxi = 0;
+ for (let j = 0; j < data['datasets'][0].data.length; j++) {
+ for (let i = 0; i < data['datasets'].length; i++) {
+ maxi = Math.max(maxi, parseInt(data['datasets'][i].data[j]));
+ }
+ }
+ return maxi;
+};
+
+export {
+ // following used by WidgetIndex.js,
+ // recruitment.js and studyProgression.js
+ setupCharts,
+};
\ No newline at end of file
diff --git a/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js b/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js
new file mode 100644
index 00000000000..097f4804808
--- /dev/null
+++ b/modules/statistics/jsx/widgets/helpers/progressbarBuilder.js
@@ -0,0 +1,95 @@
+/**
+ * progressBarBuilder - generates the graph content.
+ *
+ * @param {object} data - data needed to generate the graph content.
+ * @return {JSX.Element} the charts to render to the widget panel.
+ */
+const progressBarBuilder = (data) => {
+ let title;
+ let content;
+ if (data['recruitment_target']) {
+ title =
+ {data['title']}
+
;
+ if (data['surpassed_recruitment']) {
+ content = (
+
+
+ The recruitment target (
+ {data['recruitment_target']}
+ ) has been passed.
+
+
+
+
+ {data['female_total']}
Females
+
+
+
+
+ {data['male_total']}
Males
+
+
+
+ Target: {data['recruitment_target']}
+
+
+
+ );
+ } else {
+ content = (
+
+
+
+ {data['female_total']}
Females
+
+
+
+
+ {data['male_total']}
Males
+
+
+
+ Target: {data['recruitment_target']}
+
+
+ );
+ }
+ } else {
+ content = (
+
+ Please add a recruitment target for {data['title']}.
+
+ );
+ }
+ return (
+ <>
+ {title}
+ {content}
+ >
+ );
+ };
+
+ export {
+ progressBarBuilder,
+ };
diff --git a/modules/statistics/jsx/widgets/helpers/queryChartForm.js b/modules/statistics/jsx/widgets/helpers/queryChartForm.js
new file mode 100644
index 00000000000..294f719bd9e
--- /dev/null
+++ b/modules/statistics/jsx/widgets/helpers/queryChartForm.js
@@ -0,0 +1,190 @@
+import React, {useEffect, useState} from 'react';
+import PropTypes from 'prop-types';
+import {SelectElement, FormElement, ButtonElement} from 'jsx/Form';
+
+
+/**
+ * QueryChartForm - a form used for statistics query to modify graphs/charts.
+ *
+ * @param {object} props
+ * @return {JSX.Element}
+ */
+const QueryChartForm = (props) => {
+ const [optionsProjects, setOptionsProjects] = useState({});
+ const [optionsCohorts, setOptionsCohorts] = useState({});
+ const [optionsSites, setOptionsSites] = useState({});
+ const [optionsVisits, setOptionsVisits] = useState({});
+ const [optionsStatus, setOptionsStatus] = useState({});
+ const [formDataObj, setFormDataObj] = useState({});
+
+ /**
+ * useEffect - modified to run when props.data updates.
+ */
+ useEffect(() => {
+ const json = props.data;
+ if (json && Object.keys(json).length !== 0) {
+ let projectOptions = {};
+ for (const [key, value] of Object.entries(json['options']['projects'])) {
+ projectOptions[key] = value;
+ }
+ setOptionsProjects(projectOptions);
+ let cohortOptions = {};
+ for (
+ const [key, value] of Object.entries(json['options']['cohorts'])
+ ) {
+ cohortOptions[key] = value;
+ }
+ setOptionsCohorts(cohortOptions);
+ let siteOptions = {};
+ for (const [key, value] of Object.entries(json['options']['sites'])) {
+ siteOptions[key] = value;
+ }
+ setOptionsSites(siteOptions);
+ let visitOptions = {};
+ for (const [key, value] of Object.entries(json['options']['visits'])) {
+ visitOptions[key] = value;
+ }
+ setOptionsVisits(visitOptions);
+ let participantStatusOptions = {};
+ for (const [key, value] of Object.entries(
+ json['options']['participantStatus']
+ )) {
+ participantStatusOptions[key] = value;
+ }
+ setOptionsStatus(participantStatusOptions);
+ }
+ }, [props.data]);
+
+ /**
+ * setFormData - Stores the value of the element in formDataObj state.
+ *
+ * @param {string} formElement - name of the form element
+ * @param {string} value - value of the form element
+ */
+ const setFormData = (formElement, value) => {
+ setFormDataObj( (prevState) => ({
+ ...prevState,
+ [formElement]: value,
+ }));
+ };
+
+ const resetFilters = () => {
+ setFormDataObj({});
+ };
+
+ /**
+ * Renders the React component.
+ *
+ * @return {JSX.Element} - React markup for component.
+ */
+ return (
+ props.callback(formDataObj)}
+ method='GET'
+ >
+ {Object.keys(props.data['options']['projects']).length > 0
+ ? <>
+ Project
+
+
+ > : null}
+ {Object.keys(props.data['options']['cohorts']).length > 0
+ ? <>
+ Cohort
+
+
+ >
+ : null}
+ {Object.keys(props.data['options']['sites']).length > 0
+ ? <>
+ Site
+
+
+ >
+ : null}
+ {Object.keys(props.data['options']['visits']).length > 0
+ ? <>
+ Visit
+
+
+ >
+ : null}
+ {Object.keys(props.data['options']['participantStatus']).length > 0
+ ? <>
+ Status
+
+
+ >
+ : null}
+
+
+
+
+
+ );
+};
+QueryChartForm.propTypes = {
+ data: PropTypes.object,
+ callback: PropTypes.func,
+ Module: PropTypes.string,
+ name: PropTypes.string,
+ id: PropTypes.string,
+};
+QueryChartForm.defaultProps = {
+ data: {},
+};
+
+export {
+ QueryChartForm,
+};
diff --git a/modules/statistics/jsx/widgets/recruitment.js b/modules/statistics/jsx/widgets/recruitment.js
index 6f4225edf6c..ae206512368 100644
--- a/modules/statistics/jsx/widgets/recruitment.js
+++ b/modules/statistics/jsx/widgets/recruitment.js
@@ -1,7 +1,11 @@
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import Loader from 'Loader';
-import Panel from 'jsx/Panel';
+import Panel from 'Panel';
+import {QueryChartForm} from './helpers/queryChartForm';
+import {progressBarBuilder} from './helpers/progressbarBuilder';
+
+import {setupCharts} from './helpers/chartBuilder';
/**
* Recruitment - a widget containing statistics for recruitment data.
@@ -11,192 +15,144 @@ import Panel from 'jsx/Panel';
*/
const Recruitment = (props) => {
const [loading, setLoading] = useState(true);
- const [overall, setOverall] = useState({});
- const [siteBreakdown, setSiteBreakdown] = useState({});
- const [projectBreakdown, setProjectBreakdown] = useState({});
+ let json = props.data;
+
+ const [chartDetails, setChartDetails] = useState({
+ 'siteBreakdown': {
+ 'agerecruitment_pie': {
+ sizing: 5,
+ title: 'Total recruitment by Age',
+ filters: '',
+ chartType: 'pie',
+ dataType: 'pie',
+ label: 'Age (Years)',
+ options: {pie: 'pie', bar: 'bar'},
+ legend: 'under',
+ },
+ 'ethnicity_pie': {
+ sizing: 5,
+ title: 'Ethnicity at Screening',
+ filters: '',
+ chartType: 'pie',
+ dataType: 'pie',
+ label: 'Ethnicity',
+ options: {pie: 'pie', bar: 'bar'},
+ legend: 'under',
+ },
+ 'siterecruitment_pie': {
+ sizing: 5,
+ title: 'Total Recruitment per Site',
+ filters: '',
+ chartType: 'pie',
+ dataType: 'pie',
+ label: 'Participants',
+ legend: '',
+ options: {pie: 'pie', bar: 'bar'},
+ },
+ 'siterecruitment_bysex': {
+ sizing: 5,
+ title: 'Biological sex breakdown by site',
+ filters: '',
+ chartType: 'bar',
+ dataType: 'bar',
+ legend: 'under',
+ options: {bar: 'bar', pie: 'pie'},
+ },
+ },
+ });
+
+ const showChart = (section, chartID) => {
+ return props.showChart(section, chartID, chartDetails, setChartDetails);
+ };
+
+ const updateFilters = (formDataObj, section) => {
+ props.updateFilters(formDataObj, section, chartDetails, setChartDetails);
+ };
- /**
- * useEffect - modified to run when props.data updates.
- */
useEffect(() => {
- const json = props.data;
if (json && Object.keys(json).length !== 0) {
- const overallData = (
-
- {progressBarBuilder(json['recruitment']['overall'])}
-
- );
- let siteBreakdownData;
- if (json['recruitment']['overall'] &&
- json['recruitment']['overall']['total_recruitment'] > 0
- ) {
- siteBreakdownData = (
- <>
-
-
- Total recruitment per site
-
-
-
-
-
- Biological sex breakdown by site
-
-
-
- >
- );
- } else {
- siteBreakdownData = (
- There have been no candidates registered yet.
- );
- }
- let projectBreakdownData = [];
- for (const [key, value] of Object.entries(json['recruitment'])) {
- if (key !== 'overall') {
- projectBreakdownData.push(
-
- {progressBarBuilder(value)}
-
- );
- }
- }
- setProjectBreakdown(projectBreakdownData);
- setOverall(overallData);
- setSiteBreakdown(siteBreakdownData);
+ setupCharts(false, chartDetails).then((data) => {
+ setChartDetails(data);
+ });
+ json = props.data;
setLoading(false);
}
}, [props.data]);
- /**
- * progressBarBuilder - generates the graph content.
- *
- * @param {object} data - data needed to generate the graph content.
- * @return {JSX.Element} the charts to render to the widget panel.
- */
- const progressBarBuilder = (data) => {
- let title;
- let content;
- if (data['recruitment_target']) {
- title =
- {data['title']}
-
;
- if (data['surpassed_recruitment']) {
- content = (
-
-
- The recruitment target (
- {data['recruitment_target']}
- ) has been passed.
-
-
-
-
- {data['female_total']}
Females
-
-
-
-
- {data['male_total']}
Males
-
-
-
- Target: {data['recruitment_target']}
-
-
-
- );
- } else {
- content = (
-
-
-
- {data['female_total']}
Females
-
-
-
-
- {data['male_total']}
Males
-
-
-
- Target: {data['recruitment_target']}
-
-
- );
- }
- } else {
- content = (
-
- Please add a recruitment target for {data['title']}.
-
- );
- }
- return (
- <>
- {title}
- {content}
- >
- );
- };
-
- /**
- * Renders the React component.
- *
- * @return {JSX.Element} - React markup for component.
- */
return loading ? : (
-
- {overall}
- >,
- title: 'Recruitment - overall',
- },
- {
- content:
+ <>
+ {
+ setupCharts(false, chartDetails);
+ }}
+ views={[
+ {
+ content:
+
+ {progressBarBuilder(json['recruitment']['overall'])}
+
,
+ title: 'Recruitment - overall',
+ },
+ {
+ content:
+ json['recruitment']['overall']
+ && json['recruitment']['overall']['total_recruitment'] > 0 ?
+ <>
+ {
+ updateFilters(formDataObj, 'siteBreakdown');
+ }}
+ />
+ {Object.keys(chartDetails['siteBreakdown']).map((chartID) => {
+ return showChart('siteBreakdown', chartID);
+ })}
+ > :
+ There have been no candidates registered yet.
,
+ title: 'Recruitment - site breakdown',
+ },
+ {
+ content:
<>
- {siteBreakdown}
+ {Object.entries(json['recruitment']).map(([key, value]) => {
+ if (key !== 'overall') {
+ return
+ {progressBarBuilder(value)}
+
;
+ }
+ })}
>,
- title: 'Recruitment - site breakdown',
- },
- {
- content:
- <>
- {projectBreakdown}
- >,
- title: 'Recruitment - project breakdown',
- },
- ]}
- />
+ title: 'Recruitment - project breakdown',
+ },
+ {
+ content:
+ <>
+ {Object.entries(json['recruitmentcohorts'])
+ .map(([key, value]) => {
+ return
+ {progressBarBuilder(value)}
+
;
+ }
+ )}
+ >,
+ title: 'Recruitment - cohort breakdown',
+ },
+ ]}
+ />
+ >
);
};
+
Recruitment.propTypes = {
data: PropTypes.object,
+ baseURL: PropTypes.string,
+ updateFilters: PropTypes.function,
+ showChart: PropTypes.function,
};
Recruitment.defaultProps = {
data: {},
diff --git a/modules/statistics/jsx/widgets/studyprogression.js b/modules/statistics/jsx/widgets/studyprogression.js
index 8da62572a7b..a0964e31b1a 100644
--- a/modules/statistics/jsx/widgets/studyprogression.js
+++ b/modules/statistics/jsx/widgets/studyprogression.js
@@ -1,7 +1,9 @@
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';
import Loader from 'Loader';
-import Panel from 'jsx/Panel';
+import Panel from 'Panel';
+import {QueryChartForm} from './helpers/queryChartForm';
+import {setupCharts} from './helpers/chartBuilder';
/**
* StudyProgression - a widget containing statistics for study data.
@@ -11,78 +13,114 @@ import Panel from 'jsx/Panel';
*/
const StudyProgression = (props) => {
const [loading, setLoading] = useState(true);
- const [siteScans, setSiteScans] = useState({});
- const [siteRecruitments, setSiteRecruitments] = useState({});
+
+ let json = props.data;
+
+ const [chartDetails, setChartDetails] = useState({
+ 'total_scans': {
+ 'scans_bymonth': {
+ sizing: 11,
+ title: 'Scan sessions per site',
+ filters: '',
+ chartType: 'line',
+ dataType: 'line',
+ label: 'Scans',
+ legend: 'under',
+ options: {line: 'line'},
+ },
+ },
+ 'total_recruitment': {
+ 'siterecruitment_line': {
+ sizing: 11,
+ title: 'Recruitment per site',
+ filters: '',
+ chartType: 'line',
+ dataType: 'line',
+ legend: '',
+ options: {line: 'line'},
+ },
+ },
+ });
+
+ const showChart = ((section, chartID) => {
+ return props.showChart(section, chartID,
+ chartDetails, setChartDetails);
+ });
/**
* useEffect - modified to run when props.data updates.
*/
useEffect(() => {
- const json = props.data;
if (json && Object.keys(json).length !== 0) {
- setSiteScans(
- json['studyprogression']['total_scans'] > 0
- ?
-
Scan sessions per site
-
-
-
- Note that the Recruitment and Study Progression charts
- include data from ineligible, excluded, and consent
- withdrawn candidates.
-
-
- : There have been no scans yet.
- );
- setSiteRecruitments(
- json['studyprogression']['recruitment']['overall']
- ['total_recruitment'] > 0
- ?
-
Recruitment per site
-
-
-
- Note that the Recruitment and Study Progression charts
- include data from ineligible, excluded, and consent
- withdrawn candidates.
-
-
- : There have been no candidates registered yet.
- );
+ setupCharts(false, chartDetails).then((data) => {
+ setChartDetails(data);
+ });
+ json = props.data;
setLoading(false);
}
}, [props.data]);
- /**
- * Renders the React component.
- *
- * @return {JSX.Element} - React markup for component.
- */
+ const updateFilters = (formDataObj, section) => {
+ props.updateFilters(formDataObj, section,
+ chartDetails, setChartDetails);
+ };
+
return loading ? : (
-
- {siteScans}
- >,
- title: 'Study Progression - site scans',
- },
- {
- content: <>
- {siteRecruitments}
- >,
- title: 'Study Progression - site recruitment',
- },
- ]}
- />
+ <>
+ {
+ setupCharts(false, chartDetails);
+ }}
+ views={[
+ {
+ content: json['studyprogression']['total_scans'] > 0 ?
+ <>
+ {
+ updateFilters(formDataObj, 'total_scans');
+ }}
+ />
+ {showChart('total_scans', 'scans_bymonth')}
+ > :
+ There have been no scans yet.
,
+ title: 'Study Progression - site scans',
+ },
+ {
+ content:
+ json['studyprogression']['recruitment']
+ ['overall']['total_recruitment']
+ > 0 ?
+ <>
+ {
+ updateFilters(formDataObj, 'total_recruitment');
+ }}
+ />
+ {showChart('total_recruitment', 'siterecruitment_line')}
+ > :
+ There have been no candidates registered yet.
,
+ title: 'Study Progression - site recruitment',
+ },
+ ]}
+ />
+ >
);
};
StudyProgression.propTypes = {
data: PropTypes.object,
+ baseURL: PropTypes.string,
+ updateFilters: PropTypes.function,
+ showChart: PropTypes.function,
};
StudyProgression.defaultProps = {
data: {},
diff --git a/modules/statistics/php/charts.class.inc b/modules/statistics/php/charts.class.inc
index 4e542ad8f10..773d38d3dba 100644
--- a/modules/statistics/php/charts.class.inc
+++ b/modules/statistics/php/charts.class.inc
@@ -84,9 +84,14 @@ class Charts extends \NDB_Page
if (count($pathparts) != 2) {
return new \LORIS\Http\Response\JSON\NotFound();
}
+
switch ($pathparts[1]) {
case 'siterecruitment_pie':
return $this->_handleSitePieData();
+ case 'agerecruitment_pie':
+ return $this->_handleAgePieData();
+ case 'ethnicity_pie':
+ return $this->_handleEthnicityPieData();
case 'siterecruitment_bysex':
return $this->_handleSiteSexBreakdown();
case 'scans_bymonth':
@@ -105,20 +110,29 @@ class Charts extends \NDB_Page
*/
private function _handleSitePieData()
{
- $DB = \NDB_Factory::singleton()->database();
-
- $recruitmentBySiteData = [];
+ $params = $this->_parseGetParameters();
$user = \NDB_Factory::singleton()->user();
$list_of_sites = $user->getStudySites();
+ $conditions = $this->_buildQueryConditions($params);
+
+ $DB = \NDB_Factory::singleton()->database();
+ $recruitmentBySiteData = [];
+
foreach ($list_of_sites as $siteID => $siteName) {
$totalRecruitment = $DB->pselectOne(
"SELECT COUNT(c.CandID)
FROM candidate c
- WHERE
- c.RegistrationCenterID=:Site AND
- c.Active='Y' AND
- c.Entity_type='Human'",
+ {$conditions['cohortJoin']}
+ {$conditions['participantStatusJoin']}
+ WHERE c.RegistrationCenterID=:Site
+ AND c.Active='Y'
+ AND c.Entity_type='Human'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['visitQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['participantStatusQuery']}",
['Site' => $siteID]
);
@@ -137,32 +151,180 @@ class Charts extends \NDB_Page
*/
private function _handleSiteSexBreakdown()
{
- $DB = \NDB_Factory::singleton()->database();
- $sexData = [];
+ $params = $this->_parseGetParameters();
$user = \NDB_Factory::singleton()->user();
$list_of_sites = $user->getStudySites();
+ $conditions = $this->_buildQueryConditions($params);
+
+ $DB = \NDB_Factory::singleton()->database();
+ $sexData = [];
+
foreach ($list_of_sites as $siteID => $siteName) {
- $sexData['labels'][] = $siteName;
- $sexData['datasets']['female'][] = $DB->pselectOne(
+ $female_data = $DB->pselectOne(
"SELECT COUNT(c.CandID)
FROM candidate c
+ {$conditions['cohortJoin']}
+ {$conditions['participantStatusJoin']}
WHERE c.RegistrationCenterID=:Site
- AND c.Sex='female' AND c.Active='Y'
- AND c.Entity_type='Human'",
+ AND c.Sex='female' AND c.Active='Y'
+ AND c.Entity_type='Human'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['visitQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['participantStatusQuery']}",
['Site' => $siteID]
);
- $sexData['datasets']['male'][] = $DB->pselectOne(
+ $male_data = $DB->pselectOne(
"SELECT COUNT(c.CandID)
FROM candidate c
- WHERE c.RegistrationCenterID=:Site AND c.Sex='male' AND c.Active='Y'
- AND c.Entity_type='Human'",
+ {$conditions['cohortJoin']}
+ {$conditions['participantStatusJoin']}
+ WHERE c.RegistrationCenterID=:Site
+ AND c.Sex='male' AND c.Active='Y'
+ AND c.Entity_type='Human'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['visitQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['participantStatusQuery']}",
['Site' => $siteID]
);
+ if ($male_data !== '0' && $female_data !== '0') {
+ $sexData['labels'][] = $siteName;
+ $sexData['datasets']['male'][] = $male_data;
+ $sexData['datasets']['female'][] = $female_data;
+ }
}
+
return (new \LORIS\Http\Response\JsonResponse($sexData));
}
+ /**
+ * Handle an incoming request for age pie data.
+ *
+ * @return ResponseInterface
+ */
+ private function _handleAgePieData()
+ {
+ $params = $this->_parseGetParameters();
+
+ $conditions = $this->_buildQueryConditions($params);
+
+ $DB = \NDB_Factory::singleton()->database();
+
+ $dates = $DB->pselect(
+ "SELECT DISTINCT c.CandID, c.DoB, c.Date_registered
+ FROM candidate c
+ {$conditions['cohortJoin']}
+ {$conditions['participantStatusJoin']}
+ WHERE c.DoB IS NOT NULL
+ AND c.DoB <= c.date_registered
+ AND c.Active='Y'
+ AND c.Entity_type='Human'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['visitQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['participantStatusQuery']}",
+ []
+ );
+
+ // Initialize an array to store the dynamic age splits
+ $ageSplits = [];
+
+ foreach ($dates as $_ => $value) {
+ // Note that age is calculated from date registered, not today
+ $ageOb = \Utility::calculateAge(
+ $value['DoB'],
+ $value['Date_registered']
+ );
+ $age = $ageOb['year'];
+
+ // Determine the starting point of the age split (a multiple of 5)
+ $startOfSplit = intval(floor($age / 5) * 5);
+
+ // Check if the age split already exists in the array
+ if (!isset($ageSplits[$startOfSplit])) {
+ // If not, create a new entry with the starting age as the key
+ $ageSplits[$startOfSplit] = 0;
+ }
+
+ // Increment the count for the corresponding age split
+ ++$ageSplits[$startOfSplit];
+ }
+
+ // Convert the dynamic age splits into the desired format
+ $recruitmentByAgeData = [];
+ foreach ($ageSplits as $startOfSplit => $count) {
+ $endOfSplit = $startOfSplit + 4; // Adjust the age range as needed
+ $label = $startOfSplit . '-' . $endOfSplit;
+ $recruitmentByAgeData[] = ["label" => $label, "total" => $count];
+ }
+
+ return (new \LORIS\Http\Response\JsonResponse($recruitmentByAgeData));
+ }
+
+ /**
+ * Handle an incoming request for ethnicity pie data.
+ *
+ * @return ResponseInterface
+ */
+ private function _handleEthnicityPieData()
+ {
+ $params = $this->_parseGetParameters();
+
+ $conditions = $this->_buildQueryConditions($params);
+
+ $DB = \NDB_Factory::singleton()->database();
+
+ $candidates = $DB->pselect(
+ "SELECT DISTINCT c.CandID, c.Ethnicity
+ FROM candidate c
+ {$conditions['cohortJoin']}
+ {$conditions['participantStatusJoin']}
+ WHERE c.Active='Y'
+ AND c.Entity_type='Human'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['visitQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['participantStatusQuery']}",
+ []
+ );
+
+ // Initialize an array to store the ethnicities
+ $ethnicities = [];
+
+ foreach ($candidates as $_ => $value) {
+ // Check if the ethnicity already exists in the array
+ if (!isset($ethnicities[$value["Ethnicity"]])) {
+ // If not, create a new entry with the ethnicity as the key
+ $ethnicities[$value["Ethnicity"]] = 0;
+ }
+
+ // Increment the count for the corresponding ethnicity
+ ++$ethnicities[$value["Ethnicity"]];
+ }
+
+ // Convert into the desired format
+ $recruitmentByEthnicityData = [];
+ foreach ($ethnicities as $id => $count) {
+ $id = str_replace("_", " ", $id);
+ $id = strtolower($id);
+ $id = ucwords($id);
+ if ($id == null) {
+ $id = "Unknown";
+ }
+ $label = $id;
+ $recruitmentByEthnicityData[] = ["label" => $label, "total" => $count];
+ }
+
+ return (new \LORIS\Http\Response\JsonResponse($recruitmentByEthnicityData));
+ }
+
+
/**
* Handle an incoming request for monthly progression
*
@@ -170,8 +332,13 @@ class Charts extends \NDB_Page
*/
private function _handleScansByMonth()
{
- $DB = \NDB_Factory::singleton()->database();
+ $params = $this->_parseGetParameters();
+ $user = \NDB_Factory::singleton()->user();
+ $list_of_sites = $user->getStudySites();
+
+ $conditions = $this->_buildQueryConditions($params, true);
+ $DB = \NDB_Factory::singleton()->database();
$scanData = [];
// Run a query to get all the data. Order matters to ensure that the
// labels are calculated in the correct order.
@@ -182,13 +349,20 @@ class Charts extends \NDB_Page
FROM files f
LEFT JOIN parameter_file pf USING (FileID)
LEFT JOIN session s ON (s.ID=f.SessionID)
+ {$conditions['participantStatusJoin']}
JOIN parameter_type pt USING (ParameterTypeID)
WHERE pt.Name='acquisition_date'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['visitQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['participantStatusQuery']}
GROUP BY MONTH(pf.Value), YEAR(pf.Value), s.CenterID, datelabel
ORDER BY YEAR(pf.Value), MONTH(pf.Value), s.CenterID",
[]
);
+ // TODO: make this work as bar data
// Create the labels.
//
// We want to ensure that every month label appear exactly once and
@@ -205,8 +379,6 @@ class Charts extends \NDB_Page
$scanData['labels'] = array_keys($labels);
// Massage the data into the appropriate format per site.
- $user = \NDB_Factory::singleton()->user();
- $list_of_sites = $user->getStudySites();
foreach ($list_of_sites as $siteID => $siteName) {
$scanData['datasets'][] = [
"name" => $siteName,
@@ -263,18 +435,30 @@ class Charts extends \NDB_Page
*/
private function _handleSiteLineData()
{
+ $params = $this->_parseGetParameters();
+ $user = \NDB_Factory::singleton()->user();
+ $list_of_sites = $user->getStudySites();
+
+ $conditions = $this->_buildQueryConditions($params);
+
$DB = \NDB_Factory::singleton()->database();
- $recruitmentData = [];
- $recruitmentStartDate = $DB->pselectOne(
- "SELECT MIN(Date_registered) FROM candidate",
- []
- );
- $recruitmentEndDate = $DB->pselectOne(
- "SELECT MAX(Date_registered) FROM candidate",
+ $recruitmentData = [];
+ $recruitmentBound = $DB->pselect(
+ "SELECT MIN(c.Date_registered), MAX(c.Date_registered)
+ FROM candidate c
+ {$conditions['cohortJoin']}
+ WHERE c.Entity_type='Human'
+ {$conditions['projectQuery']}
+ {$conditions['cohortQuery']}
+ {$conditions['siteQuery']}
+ {$conditions['visitQuery']}",
[]
);
+ $recruitmentStartDate = $recruitmentBound[0]['MIN(c.Date_registered)'];
+ $recruitmentEndDate = $recruitmentBound[0]['MAX(c.Date_registered)'];
+
if ($recruitmentStartDate !== null
&& $recruitmentEndDate !== null
) {
@@ -282,26 +466,154 @@ class Charts extends \NDB_Page
new \DateTimeImmutable($recruitmentStartDate),
new \DateTimeImmutable($recruitmentEndDate)
);
+ } else {
+ $recruitmentData['labels'] = [];
}
- $user = \NDB_Factory::singleton()->user();
- $list_of_sites = $user->getStudySites();
-
foreach ($list_of_sites as $siteID => $siteName) {
- if (!isset($recruitmentData['labels'])) {
- continue;
- }
$recruitmentData['datasets'][] = [
"name" => $siteName,
"data" => $this->_getSiteLineRecruitmentData(
$siteID,
$recruitmentData['labels'],
+ $conditions['cohortJoin'],
+ $conditions['participantStatusJoin'],
+ $conditions['cohortQuery'],
+ $conditions['projectQuery'],
+ $conditions['participantStatusQuery']
),
];
}
return new \LORIS\Http\Response\JsonResponse($recruitmentData);
}
+ /**
+ * Helper to parse the GET parameters from the incoming request.
+ *
+ * @return array
+ */
+ private function _parseGetParameters()
+ {
+ $selectedProjects = empty($_GET['selectedProjects'])
+ || $_GET['selectedProjects'] === 'null'
+ || $_GET['selectedProjects'] === 'undefined'
+ ? null : explode(",", $_GET['selectedProjects']);
+ $selectedCohorts = empty($_GET['selectedCohorts'])
+ || $_GET['selectedCohorts'] === 'null'
+ || $_GET['selectedCohorts'] === 'undefined'
+ ? null : explode(",", $_GET['selectedCohorts']);
+ $selectedSites = empty($_GET['selectedSites'])
+ || $_GET['selectedSites'] === 'null'
+ || $_GET['selectedSites'] === 'undefined'
+ ? null : explode(",", $_GET['selectedSites']);
+ $selectedVisits = empty($_GET['selectedVisits'])
+ || $_GET['selectedVisits'] === 'null'
+ || $_GET['selectedVisits'] === 'undefined'
+ ? null : explode(",", $_GET['selectedVisits']);
+ $selectedParticipantStatus = empty(
+ $_GET['selectedParticipantStatus']
+ )
+ || $_GET['selectedParticipantStatus'] === 'null'
+ || $_GET['selectedParticipantStatus'] === 'undefined' ? null
+ : explode(
+ ",",
+ $_GET['selectedParticipantStatus']
+ );
+
+ return [
+ 'selectedProjects' => $selectedProjects,
+ 'selectedCohorts' => $selectedCohorts,
+ 'selectedSites' => $selectedSites,
+ 'selectedVisits' => $selectedVisits,
+ 'selectedParticipantStatus' => $selectedParticipantStatus,
+ ];
+ }
+
+ /**
+ * Helper to generate query conditions for for incoming requests.
+ *
+ * @param array $params The parameters from the incoming request.
+ * @param bool $scansbymonth Whether or not the request is for scans by month.
+ *
+ * @return array
+ */
+ private function _buildQueryConditions(
+ $params,
+ $scansbymonth = false
+ ) {
+ $user = \NDB_Factory::singleton()->user();
+
+ $projectQuery = '';
+ $cohortQuery = '';
+ $cohortJoin = '';
+ $visitQuery = '';
+ $PSJoin = '';
+ $participantStatusQuery = '';
+
+ if (!is_null($params['selectedProjects'])) {
+ $projectString = "'" . implode("','", $params['selectedProjects']) . "'";
+ $projectQuery = " AND c.RegistrationProjectID IN ({$projectString}) ";
+ }
+
+ if (!is_null($params['selectedCohorts'])) {
+ $cohortString = "'" . implode("','", $params['selectedCohorts']) . "'";
+ $cohortQuery = " AND s.CohortID IN ({$cohortString}) ";
+ $cohortJoin = "JOIN session s ON s.CandID=c.CandID";
+ }
+ if (!is_null($params['selectedSites'])) {
+ // Set site query if selected
+ $siteString = "'" . implode("','", $params['selectedSites']) . "'";
+ $siteQuery = " AND c.RegistrationCenterID IN ({$siteString}) ";
+ if ($scansbymonth === true) {
+ $siteQuery = " AND s.CenterID IN ({$siteString}) ";
+ }
+ } else {
+ // If not selected, only take user sites
+ $centerIDs = $user->getCenterIDs();
+ $centerList = "'" . implode("','", $centerIDs) . "'";
+ $siteQuery = " AND c.RegistrationCenterID IN ({$centerList}) ";
+ if ($scansbymonth === true) {
+ $siteQuery = " AND s.CenterID IN ({$centerList}) ";
+ }
+ }
+ if (!is_null($params['selectedVisits'])) {
+ // Set visit query if visits selected
+ $visitString = "'" . implode("','", $params['selectedVisits']) . "'";
+ $visitQuery = " AND s.Visit_label IN ({$visitString}) ";
+ // since they are the same, if visits are selected,
+ // then the cohort string is overwritten
+ $cohortJoin = "JOIN session s ON s.CandID=c.CandID";
+ }
+ if (!is_null($params['selectedParticipantStatus'])) {
+ $PSJoin = 'LEFT JOIN participant_status ps ON c.CandID=ps.CandID';
+ $participantStatusString = "'" . implode(
+ "','",
+ $params['selectedParticipantStatus']
+ ) . "'";
+ // Null participant status counts as Active because
+ // sometimes users do not update the participant_status tab
+ if (in_array('1', $params['selectedParticipantStatus'])) {
+ $participantStatusQuery = " AND (
+ ps.participant_status IN ({$participantStatusString})
+ OR ps.participant_status IS NULL
+ )";
+ } else {
+ $participantStatusQuery = " AND ps.participant_status
+ IN ({$participantStatusString}) ";
+ }
+ }
+
+ return [
+ 'projectQuery' => $projectQuery,
+ 'cohortQuery' => $cohortQuery,
+ 'cohortJoin' => $cohortJoin,
+ 'visitQuery' => $visitQuery,
+ 'siteQuery' => $siteQuery,
+ 'participantStatusJoin' => $PSJoin,
+ 'participantStatusQuery' => $participantStatusQuery,
+ ];
+ }
+
/**
* Helper to generate labels for every month between startDate and endDate.
*
@@ -333,27 +645,50 @@ class Charts extends \NDB_Page
/**
* Helper to generate the data for the site recruitment line for $siteID.
*
- * @param int $siteID The centerID to get data for.
- * @param array $labels The list of labels on the chart to fill the data for.
+ * @param int $siteID The centerID to get data for.
+ * @param array $labels The list of labels on the chart
+ * to fill the data for.
+ * @param String $cohortJoin The join statement for cohort if needed
+ * @param String $PSJoin The join statement for
+ * participantStatusJoin
+ * if needed
+ * @param String $cohortQuery The where statement for cohort if needed
+ * @param String $projectQuery The where statement
+ * for project if needed
+ * @param String $participantStatusQuery The where statement for
+ * participantStatus if needed
*
* @return array
*/
- private function _getSiteLineRecruitmentData($siteID, $labels)
- {
+ private function _getSiteLineRecruitmentData(
+ $siteID,
+ $labels,
+ $cohortJoin,
+ $PSJoin,
+ $cohortQuery,
+ $projectQuery,
+ $participantStatusQuery
+ ) {
$DB = \NDB_Factory::singleton()->database();
$data = [];
foreach ($labels as $label) {
- $month = (strlen($label) == 6)
+ $month = (strlen($label) == 6)
? substr($label, 0, 1) : substr($label, 0, 2);
- $year = substr($label, -4, 4);
+ $year = substr($label, -4, 4);
+
$data[] = $DB->pselectOne(
"SELECT COUNT(c.CandID)
FROM candidate c
+ {$cohortJoin}
+ {$PSJoin}
WHERE c.RegistrationCenterID=:Site
AND MONTH(c.Date_registered)=:Month
AND YEAR(c.Date_registered)=:Year
- AND c.Entity_type='Human'",
+ AND c.Entity_type='Human'
+ {$cohortQuery}
+ {$projectQuery}
+ {$participantStatusQuery}",
[
'Site' => $siteID,
'Month' => $month,
diff --git a/modules/statistics/php/widgets.class.inc b/modules/statistics/php/widgets.class.inc
index 9afb6d07466..7d53c797a97 100644
--- a/modules/statistics/php/widgets.class.inc
+++ b/modules/statistics/php/widgets.class.inc
@@ -76,38 +76,201 @@ class Widgets extends \NDB_Page implements ETagCalculator
)
];
- $projects = \Utility::getProjectList();
- foreach (array_keys($projects) as $projectID) {
- $projectInfo = $config->getProjectSettings($projectID);
+ $user = $factory->user();
+ $projects = $user->getProjectIDs();
+
+ $projectOptions = [];
+ $cohortOptions = [];
+ $visitOptions = [];
+ $recruitmentCohorts = [];
+ foreach ($projects as $projectID) {
+ // Set project recruitment data
+ $projectInfo = $config->getProjectSettings(intval(strval($projectID)));
if (is_null($projectInfo)) {
throw new \LorisException(
'No project settings exist in the Database for ' .
- 'project ID ' . intval($projectID)
+ 'project ID ' . intval(strval($projectID))
);
}
- $recruitment[$projectID] = $this->_createProjectProgressBar(
- $projectID,
- $projectInfo['Name'],
- $projectInfo['recruitmentTarget'],
- $this->getTotalRecruitmentByProject($db, $projectID),
- $db
- );
+ $recruitment[intval(strval($projectID))]
+ = $this->_createProjectProgressBar(
+ strval($projectID),
+ $projectInfo['Name'],
+ $projectInfo['recruitmentTarget'],
+ $this->getTotalRecruitmentByProject($db, $projectID),
+ $db
+ );
+
+ // Set cohort recruitment data
+ $project = \Project::getProjectFromID($projectID);
+ $cohorts = $project->getCohorts();
+
+ $projectOptions[strval($projectID)] = $project->getName();
+ foreach ($cohorts AS $sp) {
+ $cohortOptions[$sp["cohortId"]] = $sp["title"];
+ $recruitmentCohorts[$sp["cohortId"]]
+ = $this->_createCohortProgressBar(
+ $sp["cohortId"],
+ $sp["title"],
+ $sp["recruitmentTarget"],
+ $db
+ );
+ $cohortVisits = \Utility::getVisitsForCohort(
+ intval($sp["cohortId"])
+ );
+ foreach ($cohortVisits as $visit) {
+ $visitOptions[$visit] = $cohortVisits[$visit];
+ }
+ }
}
- $values = [];
+ $sites = \Utility::getSiteList();
+ $userCenters = $user->getCenterIDs();
+
+ $siteOptions = array_intersect_key($sites, $userCenters);
+
+ $participantStatusOptions
+ = \Candidate::getParticipantStatusOptions();
+
+ $options = [
+ 'projects' => $projectOptions,
+ 'cohorts' => $cohortOptions,
+ 'sites' => $siteOptions,
+ 'visits' => $visitOptions,
+ 'participantStatus' => $participantStatusOptions
+ ];
+ $values = [];
// Used for the react widget recruitment.js
$values['recruitment'] = $recruitment;
// Used for the react widget studyprogression.js
- $values['studyprogression'] = [
+ $values['studyprogression'] = [
'total_scans' => $totalScans,
'recruitment' => $recruitment
];
+ $values['options'] = $options;
+ $values['recruitmentcohorts'] = $recruitmentCohorts;
$this->_cache = new \LORIS\Http\Response\JsonResponse($values);
return $this->_cache;
}
+ /**
+ * Generates the template data for a progress bar.
+ *
+ * @param $ID The name of the progress bar being
+ * created.
+ * @param $title The title to add to the template variables.
+ * @param $recruitmentTarget The target for this recruitment type.
+ * @param \Database $db The database connection to get data from.
+ *
+ * @return array Smarty template data
+ */
+ private function _createCohortProgressBar(
+ $ID,
+ $title,
+ $recruitmentTarget,
+ \Database $db
+ ) {
+
+ $user = \User::singleton();
+ $projectIDs = $user->getProjectIDs();
+ $projectList = "'" . implode("','", $projectIDs) . "'";
+ $centerIDs = $user->getCenterIDs();
+ $centerList = "'" . implode("','", $centerIDs) . "'";
+
+ $totalRecruitment = intval(
+ $db->pselectOne(
+ "SELECT COUNT(DISTINCT s.CandID)
+ FROM session s JOIN candidate c
+ WHERE s.CohortID=:sid
+ AND s.CenterID IN ({$centerList})
+ AND s.ProjectID IN ({$projectList})
+ AND c.RegistrationCenterID <> 1",
+ ['sid' => $ID]
+ )
+ );
+
+ $rv = [
+ 'total_recruitment' => $totalRecruitment,
+ 'title' => $title,
+ ];
+
+ if (empty($recruitmentTarget)) {
+ $recruitmentTarget = $totalRecruitment;
+ }
+
+ $rv['recruitment_target'] = $recruitmentTarget;
+ $totalFemales = $this->getTotalSexByCohort(
+ $db,
+ "Female",
+ intval($ID)
+ );
+ $rv['female_total'] = $totalFemales;
+ $rv['female_percent'] = $recruitmentTarget ?
+ round($totalFemales / $recruitmentTarget * 100) :
+ null;
+ $totalMales = $this->getTotalSexByCohort(
+ $db,
+ "Male",
+ intval($ID)
+ );
+ $rv['male_total'] = $totalMales;
+ $rv['male_percent'] = $recruitmentTarget ?
+ round($totalMales / $recruitmentTarget * 100) :
+ null;
+ if ($totalRecruitment > $recruitmentTarget) {
+ $rv['surpassed_recruitment'] = "true";
+
+ $rv['female_full_percent'] = $totalRecruitment ?
+ round($totalFemales / $totalRecruitment * 100) :
+ null;
+
+ $rv['male_full_percent'] = $totalRecruitment ?
+ round($totalMales / $totalRecruitment * 100) :
+ null;
+ }
+ return $rv;
+ }
+
+ /**
+ * Gets the total count of candidates of a specific sex,
+ * associated with a specific project
+ *
+ * @param \Database $DB A database connection to retrieve information
+ * from.
+ * @param string $sex A biological sex (male or female)
+ * @param int $cohortID Cohort ID
+ *
+ * @return int|string
+ */
+ function getTotalSexByCohort(\Database $DB, string $sex, int $cohortID)
+ {
+
+ $user = \User::singleton();
+ $projectIDs = $user->getProjectIDs();
+ $projectList = "'" . implode("','", $projectIDs) . "'";
+ $centerIDs = $user->getCenterIDs();
+ $centerList = "'" . implode("','", $centerIDs) . "'";
+
+ return intval(
+ $DB->pselectOne(
+ "SELECT COUNT(DISTINCT s.CandID)
+ FROM session s
+ JOIN candidate c ON c.CandID=s.CandID
+ WHERE CohortID=:sid
+ AND CenterID IN ({$centerList})
+ AND ProjectID IN ({$projectList})
+ AND c.Sex=:sex
+ AND c.RegistrationCenterID <> 1",
+ [
+ 'sex' => $sex,
+ 'sid' => $cohortID
+ ]
+ )
+ );
+ }
+
/**
* Gets the total count of candidates associated with a specific project
*
@@ -158,7 +321,11 @@ class Widgets extends \NDB_Page implements ETagCalculator
if ($ID == 'overall') {
$totalFemales = $this->_getTotalSex($db, "Female");
} else {
- $totalFemales = $this->getTotalSexByProject($db, "Female", intval($ID));
+ $totalFemales = $this->getTotalSexByProject(
+ $db,
+ "Female",
+ intval(strval($ID))
+ );
}
$rv['female_total'] = $totalFemales;
$rv['female_percent']
@@ -166,7 +333,11 @@ class Widgets extends \NDB_Page implements ETagCalculator
if ($ID == 'overall') {
$totalMales = $this->_getTotalSex($db, "Male");
} else {
- $totalMales = $this->getTotalSexByProject($db, "Male", intval($ID));
+ $totalMales = $this->getTotalSexByProject(
+ $db,
+ "Male",
+ intval(strval($ID))
+ );
}
$rv['male_total'] = $totalMales;
$rv['male_percent']
@@ -219,7 +390,9 @@ class Widgets extends \NDB_Page implements ETagCalculator
return $DB->pselectOneInt(
"SELECT COUNT(c.CandID)
FROM candidate c
- WHERE c.Sex=:sex AND c.Active='Y' AND c.RegistrationProjectID=:PID
+ WHERE c.Sex=:sex
+ AND c.Active='Y'
+ AND c.RegistrationProjectID=:PID
AND c.Entity_type='Human' AND c.RegistrationCenterID <> 1",
[
'sex' => $sex,
@@ -231,14 +404,15 @@ class Widgets extends \NDB_Page implements ETagCalculator
/**
* Gets the total count of candidates associated with a specific project.
*
- * @param \Database $db A database connection to retrieve information
- * from.
- * @param int $projectID The Project ID to get recruitment for.
+ * @param \Database $db A database connection to retrieve information
+ * from.
+ * @param \ProjectID $projectID The Project ID to get recruitment for.
*
* @return int
*/
- function getTotalRecruitmentByProject(\Database $db, int $projectID): int
+ function getTotalRecruitmentByProject(\Database $db, \ProjectID $projectID): int
{
+ $projectID = intval(strval($projectID));
return $db->pselectOneInt(
"SELECT COUNT(*)
FROM candidate c