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 }; +}