Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import ActiveDeficientCitationsWithTtaSupport from '../../../widgets/ActiveDeficientCitationsWithTtaSupport';
import ActiveNoncompliantCitationsWithTtaSupport from '../../../widgets/ActiveNoncompliantCitationsWithTtaSupport';
import CompliantFollowUpReviewsWithTtaSupport from '../../../widgets/CompliantFollowUpReviewsWithTtaSupport';
import FindingCategoryHotspot from '../../../widgets/FindingCategoryHotspot';
import MonitoringRelatedTta from '../../../widgets/MonitoringRelatedTta';
import MonitoringReportDashboardOverview from '../../../widgets/MonitoringReportDashboardOverview';
Expand All @@ -22,6 +23,9 @@ export default function MonitoringReportDashboard({ filtersToApply }) {
<Grid row>
<FindingCategoryHotspot filters={filtersToApply} />
</Grid>
<Grid row>
<CompliantFollowUpReviewsWithTtaSupport filters={filtersToApply} />
</Grid>
<Grid row>
<MonitoringRelatedTta filters={filtersToApply} />
</Grid>
Expand Down
Empty file.
341 changes: 341 additions & 0 deletions frontend/src/widgets/CompliantFollowUpReviewsWithTtaSupport.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
import PropTypes from 'prop-types';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import createPlotlyComponent from 'react-plotly.js/factory';
import AppLoadingContext from '../AppLoadingContext';
import ContentFromFeedByTag from '../components/ContentFromFeedByTag';
import Drawer from '../components/Drawer';
import DrawerTriggerButton from '../components/DrawerTriggerButton';
import WidgetContainer from '../components/WidgetContainer';
import WidgetContainerSubtitle from '../components/WidgetContainer/WidgetContainerSubtitle';
import colors from '../colors';
import useMediaCapture from '../hooks/useMediaCapture';
import useSize from '../hooks/useSize';
import useWidgetExport from '../hooks/useWidgetExport';
import useWidgetMenuItems from '../hooks/useWidgetMenuItems';
import HorizontalTableWidget from './HorizontalTableWidget';
import withWidgetData from './withWidgetData';
import './CompliantFollowUpReviewsWithTtaSupport.css';
import NoResultsFound from '../components/NoResultsFound';

let Plot = null;
import('plotly.js-basic-dist').then((Plotly) => {
Plot = createPlotlyComponent(Plotly);
});

const SERIES_COLORS = [
colors.ttahubMediumBlue,
colors.ttahubOrange,
colors.ttahubMediumDeepTeal,
];

const SMALL_VALUE_LABEL_THRESHOLD = 2;
const MIN_INSIDE_LABEL_HEIGHT_PX = 14;

function CompliantReviewsGrid({ data, widgetRef }) {
const [plotData, setPlotData] = useState(null);
const size = useSize(widgetRef);

useEffect(() => {
if (!data || !size) return;
const { months, reviews } = data;
// Exclude Total; put "with TTA" first so "without TTA" renders on top in stacked mode
const filtered = (reviews || []).filter((s) => !/total/i.test(s.name));
const withTta = filtered.filter((s) => /with tta/i.test(s.name));
const withoutTta = filtered.filter((s) => !/with tta/i.test(s.name));
const ordered = [...withTta, ...withoutTta];
const monthlyTotals = months.map((_, monthIndex) => ordered
.reduce((sum, series) => sum + Number(series.values?.[monthIndex] || 0), 0));
const maxMonthlyTotal = monthlyTotals.length ? Math.max(...monthlyTotals) : 0;
const chartAreaHeight = 350 - 28 - 80;
const dynamicSmallValueThreshold = maxMonthlyTotal > 0
? (MIN_INSIDE_LABEL_HEIGHT_PX / chartAreaHeight) * maxMonthlyTotal
: SMALL_VALUE_LABEL_THRESHOLD;
const annotationBaseOffset = Math.max(1, Math.ceil(maxMonthlyTotal * 0.02));
const annotationStep = Math.max(1, Math.ceil(maxMonthlyTotal * 0.05));

const outsideAnnotations = [];
const outsideSeriesByMonth = [];

months.forEach((month, monthIndex) => {
const monthSeries = ordered
.map((series, seriesIndex) => ({
seriesIndex,
name: series.name,
value: Number(series.values?.[monthIndex] || 0),
}));

const smallValueSeries = monthSeries.filter(
({ value }) => value === 0
|| value <= SMALL_VALUE_LABEL_THRESHOLD
|| value <= dynamicSmallValueThreshold,
);

// If any segment is too small for an inside label, move all labels for that month above the stack.
const renderAllOutsideForMonth = smallValueSeries.length > 0;
const monthOutsideSeries = renderAllOutsideForMonth ? monthSeries : smallValueSeries;
outsideSeriesByMonth[monthIndex] = new Set(monthOutsideSeries.map(({ seriesIndex }) => seriesIndex));

const labelsToRender =
monthOutsideSeries.length > 1 && monthOutsideSeries.every(({ value }) => value === 0)
? [{ value: 0 }]
: monthOutsideSeries;

// When multiple labels are above a stack, start higher so the first label does not crowd the bar top.
const startStep = labelsToRender.length > 1 ? 1 : 0;
const stepSize = labelsToRender.length > 1
? Math.max(1, Math.ceil(annotationStep * 1.3))
: annotationStep;

labelsToRender.forEach(({ value }, labelIndex) => {
outsideAnnotations.push({
x: month,
y: monthlyTotals[monthIndex] + annotationBaseOffset + ((startStep + labelIndex) * stepSize),
text: value.toString(),
showarrow: false,
font: { color: colors.baseDarkest, size: 10 },
xanchor: 'center',
yanchor: 'bottom',
});
});
});

const traces = ordered.map((series, i) => ({
type: 'bar',
name: series.name,
x: months,
y: series.values,
text: series.values.map((v, monthIndex) => {
const isOutside = outsideSeriesByMonth[monthIndex]?.has(i);
return isOutside ? '' : v.toString();
}),
textposition: 'inside',
insidetextanchor: 'middle',
insidetextfont: { color: i === 0 ? '#fff' : colors.baseDarkest, size: 10 },
marker: { color: SERIES_COLORS[i % SERIES_COLORS.length] },
hovertemplate: '%{y}<extra></extra>',
hoverlabel: {
bgcolor: colors.baseDarkest,
bordercolor: colors.baseDarkest,
font: { color: '#fff', size: 16 },
},
}));

setPlotData({
traces,
layout: {
barmode: 'stack',
height: 350,
width: size.width,
margin: { l: 90, r: 20, t: 28, b: 80 },
font: { color: colors.baseDarkest },
xaxis: { automargin: true, title: { text: 'Follow-up review received date', font: { family: 'Source Sans Pro, sans-serif', size: 16 } } },
yaxis: {
tickformat: ',.0d',
autorange: true,
title: { text: 'Compliant follow-up reviews', font: { family: 'Source Sans Pro, sans-serif', size: 16 } },
},
annotations: outsideAnnotations,
showlegend: false,
},
config: { responsive: true, displayModeBar: false },
});
}, [data, size]);

return (
<div ref={widgetRef} className="padding-3">
{plotData && (
<>
<div
style={{
display: 'flex',
justifyContent: 'center',
gap: '24px',
marginBottom: '8px',
fontFamily: 'Source Sans Pro, sans-serif',
fontSize: '16px',
}}
>
{plotData.traces.map((trace) => (
<div
key={trace.name}
style={{ display: 'flex', alignItems: 'center', gap: '10px' }}
>
<div
style={{
width: '26px',
height: '26px',
borderRadius: '4px',
backgroundColor: trace.marker.color,
flexShrink: 0,
}}
/>
<span>{trace.name}</span>
</div>
))}
</div>
<Plot data={plotData.traces} layout={plotData.layout} config={plotData.config} />
</>
)}
</div>
);
}

CompliantReviewsGrid.propTypes = {
data: PropTypes.shape({
months: PropTypes.arrayOf(PropTypes.string),
reviews: PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.number),
})
),
}).isRequired,
widgetRef: PropTypes.shape({ current: PropTypes.instanceOf(Element) }).isRequired,
};

const EXPORT_NAME = 'Compliant follow-up reviews with TTA support';

export function CompliantFollowUpReviewsWithTtaSupport({ loading, data }) {
const { setIsAppLoading } = useContext(AppLoadingContext);
const drawerTriggerRef = useRef(null);
const widgetRef = useRef(null);
const capture = useMediaCapture(widgetRef, EXPORT_NAME);
const [showTabularData, setShowTabularData] = useState(false);

Comment on lines +198 to +204
useEffect(() => {
setIsAppLoading(loading);
}, [loading, setIsAppLoading]);

const months = useMemo(() => {
if (!data || data.length === 0) return [];
return data.months;
}, [data]);
Comment on lines +209 to +212

// Build rows for HorizontalTableWidget (table view / export)
// Separate non-Total rows from the Total row so it can go in tfoot (bold)
const { tableData, footerData } = useMemo(() => {
const reviews = data?.reviews || [];
const nonTotalRows = reviews.filter((row) => !/total/i.test(row.name));
const totalRow = reviews.find((row) => /total/i.test(row.name));

const rows = nonTotalRows.map((row) => ({
heading: row.name,
id: row.name,
tooltip: true,
hideSortingIndicator: true,
data: [
...row.values.map((value) => ({ value: value.toString() })),
{ value: row.values.reduce((sum, v) => sum + Number(v), 0).toString() },
],
}));

const footer = totalRow
? ['Total', ...totalRow.values.map(String), totalRow.values.reduce((sum, v) => sum + Number(v), 0).toString()]
: false;

return { tableData: rows, footerData: footer };
}, [data]);

const { exportRows } = useWidgetExport(
tableData,
[...(months || []), 'Total'],
{},
'Follow-up reviews',
EXPORT_NAME
);

const menuItems = useWidgetMenuItems(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex said: This still exposes an Export table action even though exportRows is never passed. useWidgetMenuItems calls exportRows() whenever the widget is in table mode, so the export action will fail the first time a user clicks it. Either wire useWidgetExport here now, or remove the export menu path until the table export is implemented.

showTabularData,
setShowTabularData,
capture,
{},
exportRows
);

const subtitle = (
<div className="margin-bottom-3">
<WidgetContainerSubtitle>
Compliant follow-up reviews, broken out by those with and without citations addressed by
approved activity reports during the correction period.
</WidgetContainerSubtitle>
<div className="margin-top-1">
<DrawerTriggerButton drawerTriggerRef={drawerTriggerRef}>
About this data
</DrawerTriggerButton>
</div>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex said: This empty-state logic is checking data.length, but this widget does not consume an array response. The backend returns an object with months and reviews, so data.length is undefined for the real success shape. That means an empty response can fall through and render a blank widget instead of NoResultsFound, and loading currently also routes through the empty-state branch. This should be based on the actual shape, for example !loading && data?.months?.length === 0 or an equivalent check on reviews.

</div>
);

const showEmptyState = loading || !data || data.length === 0;

Comment on lines +269 to +270
if (showEmptyState) {
return (
<>
<Drawer triggerRef={drawerTriggerRef} title="Compliant Follow-up Reviews with TTA Support">
<ContentFromFeedByTag tagName="ttahub-compliant-follow-up-reviews-with-tta-support" />
</Drawer>
<WidgetContainer
title="Compliant Follow-up Reviews with TTA Support"
subtitle={subtitle}
menuItems={[]}
loading={loading}
titleMargin={{ bottom: 1 }}
>
<NoResultsFound
drawerConfig={{
tagName: 'ttahub-regional-dash-monitoring-filters',
title: 'Monitoring dashboard filters',
}}
/>
</WidgetContainer>
</>
);
}

return (
<>
<Drawer triggerRef={drawerTriggerRef} title="Compliant Follow-up Reviews with TTA Support">
<ContentFromFeedByTag tagName="ttahub-compliant-follow-up-reviews-with-tta-support" />
</Drawer>
<WidgetContainer
title="Compliant Follow-up Reviews with TTA Support"
subtitle={subtitle}
menuItems={menuItems}
loading={loading}
titleMargin={{ bottom: 1 }}
>
{showTabularData ? (
<HorizontalTableWidget
headers={months}
data={tableData}
caption="Compliant Follow-up Reviews"
firstHeading="Follow-up reviews"
lastHeading="Totals"
showTotalColumn
stickyFirstColumn
stickyLastColumn
enableCheckboxes={false}
selectAllIdPrefix="compliant-follow-up-reviews"
hideFirstColumnBorder
footerData={footerData}
/>
) : (
<CompliantReviewsGrid data={data} widgetRef={widgetRef} />
)}
Comment on lines +307 to +324
</WidgetContainer>
</>
);
}

CompliantFollowUpReviewsWithTtaSupport.propTypes = {
filters: PropTypes.arrayOf(PropTypes.shape({})).isRequired,
};

CompliantFollowUpReviewsWithTtaSupport.defaultProps = {
data: [],
};
Comment on lines +330 to +336

export default withWidgetData(
CompliantFollowUpReviewsWithTtaSupport,
'compliantFollowUpReviewsWithTtaSupport'
);
Loading
Loading