diff --git a/frontend/src/pages/RecipientRecord/pages/TTAHistory.js b/frontend/src/pages/RecipientRecord/pages/TTAHistory.js
index b053b7a998..51febdf6b2 100644
--- a/frontend/src/pages/RecipientRecord/pages/TTAHistory.js
+++ b/frontend/src/pages/RecipientRecord/pages/TTAHistory.js
@@ -10,7 +10,7 @@ import useSessionFiltersAndReflectInUrl from '../../../hooks/useSessionFiltersAn
import { getUserRegions } from '../../../permissions';
import UserContext from '../../../UserContext';
import { expandFilters, formatDateRange } from '../../../utils';
-import Overview from '../../../widgets/DashboardOverview';
+import { TTAHistoryOverview } from '../../../widgets/DashboardOverview';
import FrequencyGraph from '../../../widgets/FrequencyGraph';
import TargetPopulationsTable from '../../../widgets/TargetPopulationsTable';
import { TTAHISTORY_FILTER_CONFIG } from './constants';
@@ -90,8 +90,8 @@ export default function TTAHistory({ recipientName, recipientId, regionId }) {
allUserRegions={regions}
/>
-
diff --git a/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js b/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js
index 5089f936e6..fa2698e364 100644
--- a/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js
+++ b/frontend/src/pages/RecipientRecord/pages/__tests__/TTAHistory.js
@@ -21,6 +21,7 @@ describe('Recipient Record - TTA History', () => {
inPerson: '0',
sumDuration: '1.0',
numParticipants: '1',
+ numSessions: '3',
};
const tableResponse = {
@@ -53,10 +54,10 @@ describe('Recipient Record - TTA History', () => {
};
beforeEach(async () => {
- const overviewUrl = `/api/widgets/overview?startDate.win=${yearToDate}®ion.in[]=1&recipientId.ctn[]=401`;
+ const ttaHistoryOverviewUrl = `/api/widgets/ttaHistoryOverview?startDate.win=${yearToDate}®ion.in[]=1&recipientId.ctn[]=401`;
const tableUrl = `/api/activity-reports?sortBy=updatedAt&sortDir=desc&offset=0&limit=10&startDate.win=${yearToDate}®ion.in[]=1&recipientId.ctn[]=401`;
- fetchMock.get(overviewUrl, overviewResponse);
+ fetchMock.get(ttaHistoryOverviewUrl, overviewResponse);
fetchMock.get(tableUrl, tableResponse);
fetchMock.get(
@@ -79,6 +80,12 @@ describe('Recipient Record - TTA History', () => {
expect(overview).toBeTruthy();
});
+ it('renders the TR sessions widget', async () => {
+ act(() => renderTTAHistory());
+ const trSessionsLabel = await screen.findByText('Training report sessions');
+ expect(trSessionsLabel).toBeTruthy();
+ });
+
it('renders the activity reports table', async () => {
renderTTAHistory();
const reports = await screen.findByText(/approved activity reports/i, { selector: 'h2' });
@@ -106,7 +113,7 @@ describe('Recipient Record - TTA History', () => {
200
);
fetchMock.get(
- '/api/widgets/overview?role.in[]=Family%20Engagement%20Specialist&role.in[]=Grantee%20Specialist®ion.in[]=1&recipientId.ctn[]=401',
+ '/api/widgets/ttaHistoryOverview?role.in[]=Family%20Engagement%20Specialist&role.in[]=Grantee%20Specialist®ion.in[]=1&recipientId.ctn[]=401',
overviewResponse
);
diff --git a/frontend/src/widgets/DashboardOverview.js b/frontend/src/widgets/DashboardOverview.js
index 6768573a37..092c340af5 100644
--- a/frontend/src/widgets/DashboardOverview.js
+++ b/frontend/src/widgets/DashboardOverview.js
@@ -81,6 +81,16 @@ const getDashboardFields = (data, showTooltip) => ({
label1: 'Activity reports',
data: data.numReports,
},
+ 'Training report sessions': {
+ key: 'training-report-sessions',
+ showTooltip,
+ tooltipText: 'The number of approved Training Report sessions.',
+ icon: faChartColumn,
+ iconColor: colors.success,
+ backgroundColor: colors.successLighter,
+ label1: 'Training report sessions',
+ data: data.numSessions,
+ },
'Training reports': {
key: 'training-reports',
showTooltip,
@@ -230,6 +240,7 @@ DashboardOverviewWidget.propTypes = {
recipientPercentage: PropTypes.string,
totalRecipients: PropTypes.string,
numRecipients: PropTypes.string,
+ numSessions: PropTypes.string,
percentCompliantFollowUpReviewsWithTtaSupport: PropTypes.string,
totalCompliantFollowUpReviewsWithTtaSupport: PropTypes.string,
totalCompliantFollowUpReviews: PropTypes.string,
@@ -256,6 +267,7 @@ DashboardOverviewWidget.defaultProps = {
totalRecipients: '0',
recipientPercentage: '0%',
numRecipients: '0',
+ numSessions: '0',
percentCompliantFollowUpReviewsWithTtaSupport: '0%',
totalCompliantFollowUpReviewsWithTtaSupport: '0',
totalCompliantFollowUpReviews: '0',
@@ -278,4 +290,6 @@ DashboardOverviewWidget.defaultProps = {
maxToolTipWidth: null,
};
+export const TTAHistoryOverview = withWidgetData(DashboardOverviewWidget, 'ttaHistoryOverview');
+
export default withWidgetData(DashboardOverviewWidget, 'overview');
diff --git a/src/widgets/index.js b/src/widgets/index.js
index c6977ba8f3..deb95662a3 100644
--- a/src/widgets/index.js
+++ b/src/widgets/index.js
@@ -19,7 +19,9 @@ import totalHrsAndRecipientGraph from './totalHrsAndRecipientGraph';
import trHoursOfTrainingByNationalCenter from './trHoursOfTrainingByNationalCenter';
import trOverview from './trOverview';
import trSessionsByTopic from './trSessionsByTopic';
+import trSessionsForRecipient from './trSessionsForRecipient';
import trStandardGoalList from './trStandardGoalList';
+import ttaHistoryOverview from './ttaHistoryOverview';
/*
All widgets need to be added to this object
@@ -38,6 +40,8 @@ export default {
trOverview,
trStandardGoalList,
trSessionsByTopic,
+ trSessionsForRecipient,
+ ttaHistoryOverview,
trHoursOfTrainingByNationalCenter,
approvalRateByDeadline,
diff --git a/src/widgets/trSessionsForRecipient.test.ts b/src/widgets/trSessionsForRecipient.test.ts
new file mode 100644
index 0000000000..ba20b2cee8
--- /dev/null
+++ b/src/widgets/trSessionsForRecipient.test.ts
@@ -0,0 +1,225 @@
+import { TRAINING_REPORT_STATUSES } from '@ttahub/common';
+import db from '../models';
+import {
+ createGrant,
+ createRecipient,
+ createSessionReport,
+ createTrainingReport,
+ createUser,
+} from '../testUtils';
+import trSessionsForRecipient from './trSessionsForRecipient';
+
+const { EventReportPilot, Grant, Recipient, SessionReportPilot, User } = db;
+
+// We need to mock this so that we don't try to send emails or otherwise engage the queue
+jest.mock('bull');
+
+describe('TR sessions for recipient widget', () => {
+ let userCreator;
+
+ let recipient1;
+ let recipient2;
+
+ let grant1;
+ let grant2;
+
+ let trainingReport1;
+ let trainingReport2;
+
+ beforeAll(async () => {
+ userCreator = await createUser();
+
+ // recipient 1 - the target recipient for counting
+ recipient1 = await createRecipient();
+ // recipient 2 - other recipient; sessions on their grants should not count for recipient 1
+ recipient2 = await createRecipient();
+
+ // grant 1 belongs to recipient 1
+ grant1 = await createGrant({ recipientId: recipient1.id, regionId: userCreator.homeRegionId });
+ // grant 2 belongs to recipient 2
+ grant2 = await createGrant({ recipientId: recipient2.id, regionId: userCreator.homeRegionId });
+
+ // --- Training report 1: sessions for recipient1 ---
+ trainingReport1 = await createTrainingReport({
+ collaboratorIds: [],
+ pocIds: [],
+ ownerId: userCreator.id,
+ });
+
+ // Session 1: has recipient1's grant, COMPLETE -> counts for recipient1
+ await createSessionReport({
+ eventId: trainingReport1.id,
+ data: {
+ deliveryMethod: 'in-person',
+ duration: 1,
+ recipients: [{ value: grant1.id }],
+ numberOfParticipantsVirtually: 0,
+ numberOfParticipantsInPerson: 0,
+ numberOfParticipants: 10,
+ status: TRAINING_REPORT_STATUSES.COMPLETE,
+ },
+ });
+
+ // Session 2: has recipient1's grant, COMPLETE -> counts for recipient1
+ await createSessionReport({
+ eventId: trainingReport1.id,
+ data: {
+ deliveryMethod: 'in-person',
+ duration: 1,
+ recipients: [{ value: grant1.id }],
+ numberOfParticipantsVirtually: 0,
+ numberOfParticipantsInPerson: 0,
+ numberOfParticipants: 10,
+ status: TRAINING_REPORT_STATUSES.COMPLETE,
+ },
+ });
+
+ // Session 3: has recipient1's grant, IN_PROGRESS -> excluded by baseTRScopes (not COMPLETE)
+ await createSessionReport({
+ eventId: trainingReport1.id,
+ data: {
+ deliveryMethod: 'in-person',
+ duration: 1,
+ recipients: [{ value: grant1.id }],
+ numberOfParticipantsVirtually: 0,
+ numberOfParticipantsInPerson: 0,
+ numberOfParticipants: 10,
+ status: TRAINING_REPORT_STATUSES.IN_PROGRESS,
+ },
+ });
+
+ await trainingReport1.update({
+ data: {
+ ...trainingReport1.data,
+ status: TRAINING_REPORT_STATUSES.COMPLETE,
+ },
+ });
+
+ // --- Training report 2: session only for recipient2 ---
+ trainingReport2 = await createTrainingReport({
+ collaboratorIds: [],
+ pocIds: [],
+ ownerId: userCreator.id,
+ });
+
+ // Session 4: only has recipient2's grant, COMPLETE -> counts for recipient2, NOT recipient1
+ await createSessionReport({
+ eventId: trainingReport2.id,
+ data: {
+ deliveryMethod: 'in-person',
+ duration: 1,
+ recipients: [{ value: grant2.id }],
+ numberOfParticipantsVirtually: 0,
+ numberOfParticipantsInPerson: 0,
+ numberOfParticipants: 10,
+ status: TRAINING_REPORT_STATUSES.COMPLETE,
+ },
+ });
+
+ await trainingReport2.update({
+ data: {
+ ...trainingReport2.data,
+ status: TRAINING_REPORT_STATUSES.COMPLETE,
+ },
+ });
+ });
+
+ afterAll(async () => {
+ await SessionReportPilot.destroy({
+ where: {
+ eventId: [trainingReport1.id, trainingReport2.id],
+ },
+ });
+
+ await EventReportPilot.destroy({
+ where: {
+ id: [trainingReport1.id, trainingReport2.id],
+ },
+ });
+
+ await db.GrantNumberLink.destroy({
+ where: {
+ grantId: [grant1.id, grant2.id],
+ },
+ force: true,
+ });
+
+ await Grant.destroy({
+ where: {
+ id: [grant1.id, grant2.id],
+ },
+ individualHooks: true,
+ });
+
+ await Recipient.destroy({
+ where: {
+ id: [recipient1.id, recipient2.id],
+ },
+ });
+
+ await User.destroy({
+ where: {
+ id: [userCreator.id],
+ },
+ });
+
+ await db.sequelize.close();
+ });
+
+ it('counts only COMPLETE sessions where the recipient has a matching grant', async () => {
+ // Scope restricted to our test data only
+ const scopes = {
+ grant: {
+ where: [{ id: [grant1.id, grant2.id] }],
+ },
+ trainingReport: [{ id: [trainingReport1.id, trainingReport2.id] }],
+ } as any;
+
+ const data = await trSessionsForRecipient(scopes, {
+ 'recipientId.ctn': [String(recipient1.id)],
+ });
+
+ // session1 + session2 = 2 (session3 is IN_PROGRESS so excluded; session4 is for recipient2)
+ expect(data.numSessions).toBe('2');
+ });
+
+ it('does not count sessions belonging only to other recipients', async () => {
+ const scopes = {
+ grant: {
+ where: [{ id: [grant1.id, grant2.id] }],
+ },
+ trainingReport: [{ id: [trainingReport1.id, trainingReport2.id] }],
+ } as any;
+
+ const data = await trSessionsForRecipient(scopes, {
+ 'recipientId.ctn': [String(recipient2.id)],
+ });
+
+ // Only session4 has grant2 (recipient2) and is COMPLETE
+ expect(data.numSessions).toBe('1');
+ });
+
+ it('returns 0 when no recipientId is provided', async () => {
+ const scopes = {
+ grant: {
+ where: [{ id: [grant1.id, grant2.id] }],
+ },
+ trainingReport: [{ id: [trainingReport1.id, trainingReport2.id] }],
+ } as any;
+
+ const data = await trSessionsForRecipient(scopes, {});
+ expect(data.numSessions).toBe('0');
+ });
+
+ it('returns 0 when an empty recipientId array is provided', async () => {
+ const scopes = {
+ grant: {
+ where: [{ id: [grant1.id, grant2.id] }],
+ },
+ trainingReport: [{ id: [trainingReport1.id, trainingReport2.id] }],
+ } as any;
+
+ const data = await trSessionsForRecipient(scopes, { 'recipientId.ctn': [] });
+ expect(data.numSessions).toBe('0');
+ });
+});
diff --git a/src/widgets/trSessionsForRecipient.ts b/src/widgets/trSessionsForRecipient.ts
new file mode 100644
index 0000000000..854be0668d
--- /dev/null
+++ b/src/widgets/trSessionsForRecipient.ts
@@ -0,0 +1,79 @@
+import { Op } from 'sequelize';
+import db from '../models';
+import { validatedIdArray } from '../scopes/utils';
+import { baseTRScopes, formatNumber } from './helpers';
+import type { IScopes } from './types';
+
+const { EventReportPilot: TrainingReport, Grant } = db;
+
+interface ISessionReport {
+ data: {
+ recipients: {
+ value: number;
+ }[];
+ };
+}
+
+interface ITrainingReportForSessionCount {
+ sessionReports: ISessionReport[];
+}
+
+/**
+ * Widget: count of approved (COMPLETE) Training Report sessions for a given recipient.
+ * Used on the RTR TTA History tab.
+ *
+ * NOTE: The `numSessions` key returned here is per-recipient and is distinct from the
+ * `numSessions` returned by `trOverview`, which is a global count across visible TRs.
+ */
+export default async function trSessionsForRecipient(
+ scopes: IScopes,
+ query: Record
+): Promise<{ numSessions: string }> {
+ const recipientIdsRaw = query['recipientId.ctn'];
+ const rawList = Array.isArray(recipientIdsRaw)
+ ? recipientIdsRaw
+ : recipientIdsRaw != null ? [recipientIdsRaw] : [];
+ const recipientIds = validatedIdArray(rawList.map((v) => String(v)));
+
+ if (!recipientIds.length) {
+ return { numSessions: '0' };
+ }
+
+ // Find all grants belonging to this recipient, respecting any active grant scopes
+ // (e.g. region) so we don't include grants the user isn't entitled to see.
+ const grants = (await Grant.findAll({
+ attributes: ['id'],
+ where: {
+ [Op.and]: [
+ { recipientId: { [Op.in]: recipientIds } },
+ scopes.grant.where,
+ ],
+ },
+ raw: true,
+ })) as { id: number }[];
+
+ const recipientGrantIds = new Set(grants.map((g) => g.id));
+
+ if (!recipientGrantIds.size) {
+ return { numSessions: '0' };
+ }
+
+ // Get completed training reports with their complete sessions via shared scope helper
+ const reports = (await TrainingReport.findAll({
+ attributes: ['id'],
+ ...baseTRScopes(scopes),
+ })) as ITrainingReportForSessionCount[];
+
+ // Count sessions where the recipient has at least one matching grant in data.recipients
+ let numSessions = 0;
+ reports.forEach((report) => {
+ report.sessionReports.forEach((session) => {
+ const sessionGrantIds = (session.data.recipients || []).map((r) => r.value);
+ if (sessionGrantIds.some((id) => recipientGrantIds.has(id))) {
+ numSessions += 1;
+ }
+ });
+ });
+
+ return { numSessions: formatNumber(numSessions) };
+}
diff --git a/src/widgets/ttaHistoryOverview.ts b/src/widgets/ttaHistoryOverview.ts
new file mode 100644
index 0000000000..4a728d72f9
--- /dev/null
+++ b/src/widgets/ttaHistoryOverview.ts
@@ -0,0 +1,21 @@
+import overview from './overview';
+import trSessionsForRecipient from './trSessionsForRecipient';
+import type { IScopes } from './types';
+
+/**
+ * Combined widget for the TTA History tab overview row.
+ * Returns all standard Activity Report overview metrics plus the count of
+ * approved Training Report sessions for the recipient, so both can be rendered
+ * in a single in-order field row.
+ */
+export default async function ttaHistoryOverview(
+ scopes: IScopes,
+ query: Record
+) {
+ const [arData, trData] = await Promise.all([
+ overview(scopes),
+ trSessionsForRecipient(scopes, query),
+ ]);
+
+ return { ...arData, numSessions: trData.numSessions };
+}