From e5b9ef6de2964576c18468f709fd35d978a0d727 Mon Sep 17 00:00:00 2001 From: Renuka <133939590+renukaj07@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:54:37 +0530 Subject: [PATCH 1/3] feat: add campaign assignments report UI and API integration --- public/locales/en/translation.json | 15 +- .../CampaignAssignmentsReport.helpers.ts | 160 ++++ .../CampaignAssignmentsReport.test.tsx | 747 ++++++++++++++++++ .../CampaignAssignmentsReport.tsx | 154 ++++ .../CampaignRewardsReport.helpers.ts | 7 + .../CampaignRewardsReport.test.tsx | 87 +- .../CampaignRewardsReport.tsx | 11 +- .../campaignsOverview/CampaignsOverview.tsx | 9 + src/services/api/ApiHandler.ts | 9 + src/services/api/ServiceApi.ts | 35 + src/services/api/sqlite/SqliteApi.campaign.ts | 12 + .../api/supabase/SupabaseApi.campaign.ts | 62 ++ src/services/database.ts | 20 + 13 files changed, 1310 insertions(+), 18 deletions(-) create mode 100644 src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts create mode 100644 src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.test.tsx create mode 100644 src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 34fbfad7b0..f91bd0740f 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -171,7 +171,9 @@ "Assessment": "Assessment", "Assign or get regular homework.": "Assign or get regular homework.", "Assign": "Assign", + "Assigned Students": "Assigned Students", "Assigned": "Assigned", + "Assignment Report": "Assignment Report", "Assignment": "Assignment", "Assignments are assigned Successfully.": "Assignments are assigned Successfully.", "Assignments Only": "Assignments Only", @@ -185,6 +187,8 @@ "Attempted": "Attempted", "Audience group saved.": "Audience group saved.", "Auto-rejected because a duplicate student request was merged and approved.": "Auto-rejected because a duplicate student request was merged and approved.", + "Average Assignments Completion": "Average Assignments Completion", + "Average number of assignments completed per active student during the selected period.": "Average number of assignments completed per active student during the selected period.", "Average percentage score of active students in the last 7 days.": "Average percentage score of active students in the last 7 days.", "Average Score": "Average Score", "Average time spent by active students in the last 7 days.": "Average time spent by active students in the last 7 days.", @@ -793,6 +797,7 @@ "Lesson Name": "Lesson Name", "Lesson starts in :": "Lesson starts in :", "Lesson": "Lesson", + "Lessons Assigned": "Lessons Assigned", "Lessons Played": "Lessons Played", "lessons": "lessons", "Let's play and learn! Complete first lesson and earn a reward!": "Let's play and learn! Complete first lesson and earn a reward!", @@ -1655,15 +1660,20 @@ "Time Remaining :": "Time Remaining :", "Time Spent": "Time Spent", "Time": "Time", + "TINY FRIENDS": "TINY FRIENDS", "Tiny shapes": "Tiny shapes", "To add more assignments. Please use the buttons below to add assignments.": "To add more assignments. Please use the buttons below to add assignments.", "To keep your data safe and comply with privacy regulations, please review and agree to continue.": "To keep your data safe and comply with privacy regulations, please review and agree to continue.", "To": "To", "Too Far": "Too Far", + "Total Assignments": "Total Assignments", "Total Count": "Total Count", "Total Missing Parents": "Total Missing Parents", "Total No. Students": "Total No. Students", + "Total number of assignments assigned through the campaign while creating the campaign.": "Total number of assignments assigned through the campaign while creating the campaign.", "Total number of students included in this campaign.": "Total number of students included in this campaign.", + "Total number of students who completed at least one assignment during the campaign period.": "Total number of students who completed at least one assignment during the campaign period.", + "Total number of students who received at least one assignment through the campaign.": "Total number of students who received at least one assignment through the campaign.", "Total Schools": "Total Schools", "Total Students": "Total Students", "Trophy": "Trophy", @@ -1673,7 +1683,6 @@ "Twitter": "Twitter", "Type your answer...": "Type your answer...", "Type your note here": "Type your note here", - "TINY FRIENDS": "TINY FRIENDS", "UDISE WhatsApp Invite Tool (1.0.1)": "UDISE WhatsApp Invite Tool (1.0.1)", "UDISE": "UDISE", "Uh-oh! Do you want to leave paint mode?": "Uh-oh! Do you want to leave paint mode?", @@ -1686,6 +1695,7 @@ "Unable to save campaign setup.": "Unable to save campaign setup.", "Unable to Upload File": "Unable to Upload File", "Unable to verify provided information": "Unable to verify provided information", + "UNDER THE SEA": "UNDER THE SEA", "Unexpected error while merging.": "Unexpected error while merging.", "Units of Measurement": "Units of Measurement", "Unknown School": "Unknown School", @@ -1711,7 +1721,7 @@ "Users": "Users", "utility": "utility", "Utsav": "Utsav", - "UNDER THE SEA": "UNDER THE SEA", + "VEGETABLE COLONY": "VEGETABLE COLONY", "Vegetable Farm": "Vegetable Farm", "Verification code has expired. Please request a new one.": "Verification code has expired. Please request a new one.", "Verification Failed": "Verification Failed", @@ -1730,7 +1740,6 @@ "View Progress": "View Progress", "Village Name": "Village Name", "Visit Website": "Visit Website", - "VEGETABLE COLONY": "VEGETABLE COLONY", "Watermelon": "Watermelon", "We are checking your uploaded data for any errors. Please wait a moment.": "We are checking your uploaded data for any errors. Please wait a moment.", "We couldn't complete the migration. Please try again later": "We couldn't complete the migration. Please try again later", diff --git a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts new file mode 100644 index 0000000000..c8831a81b4 --- /dev/null +++ b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts @@ -0,0 +1,160 @@ +import { useEffect, useMemo, useState } from 'react'; +import { t } from 'i18next'; +import { ServiceConfig } from '../../../services/ServiceConfig'; +import logger from '../../../utility/logger'; + +export type CampaignAssignmentsSummaryCard = { + key: string; + label: string; + value: string; + info: string; +}; + +export type CampaignAssignmentsTableRow = { + id: string; + subject: string; + lessonsAssigned: number; + completionPercent: string; +}; + +export const CAMPAIGN_ASSIGNMENTS_REPORT_SUBTAB = 'Assignments'; + +export const buildCampaignAssignmentsSummaryCards = ({ + activeStudents, + assignedStudents, + averageAssignmentsCompletion, + totalAssignments, +}: { + activeStudents: number; + assignedStudents: number; + averageAssignmentsCompletion: number; + totalAssignments: number; +}): CampaignAssignmentsSummaryCard[] => [ + { + key: 'totalAssignments', + label: t('Total Assignments'), + value: String(totalAssignments), + info: t( + 'Total number of assignments assigned through the campaign while creating the campaign.', + ), + }, + { + key: 'assignedStudents', + label: t('Assigned Students'), + value: assignedStudents.toLocaleString('en-IN'), + info: t( + 'Total number of students who received at least one assignment through the campaign.', + ), + }, + { + key: 'activeStudents', + label: t('Active Students'), + value: activeStudents.toLocaleString('en-IN'), + info: t( + 'Total number of students who completed at least one assignment during the campaign period.', + ), + }, + { + key: 'averageAssignmentsCompletion', + label: t('Average Assignments Completion'), + value: `${averageAssignmentsCompletion.toLocaleString('en-IN', { + maximumFractionDigits: 1, + })}%`, + info: t( + 'Average number of assignments completed per active student during the selected period.', + ), + }, +]; + +export const mapAssignmentReportRows = ( + rows: Array<{ + subjectId: string; + subjectName: string; + lessonsAssigned: number; + completionPercent: number; + }>, +): CampaignAssignmentsTableRow[] => + rows.map((row) => ({ + id: row.subjectId, + subject: row.subjectName, + lessonsAssigned: row.lessonsAssigned, + completionPercent: `${Math.round(row.completionPercent)}%`, + })); + +export const useCampaignAssignmentsReportState = ( + campaignId?: string, + totalStudents?: number | null, +) => { + const [loading, setLoading] = useState(false); + const [rows, setRows] = useState([]); + const [summaryCards, setSummaryCards] = useState< + CampaignAssignmentsSummaryCard[] + >([]); + + useEffect(() => { + let active = true; + + const loadReport = async () => { + if (!campaignId) { + setRows([]); + setSummaryCards( + buildCampaignAssignmentsSummaryCards({ + totalAssignments: 0, + assignedStudents: 0, + activeStudents: 0, + averageAssignmentsCompletion: 0, + }), + ); + return; + } + + setLoading(true); + try { + const response = + await ServiceConfig.getI().apiHandler.getCampaignAssignmentsReport( + campaignId, + { totalStudents: totalStudents ?? 0 }, + ); + if (!active) return; + setRows(mapAssignmentReportRows(response.rows)); + setSummaryCards( + buildCampaignAssignmentsSummaryCards({ + totalAssignments: response.summary.totalAssignments, + assignedStudents: response.summary.assignedStudents, + activeStudents: response.summary.activeStudents, + averageAssignmentsCompletion: + response.summary.averageAssignmentsCompletion, + }), + ); + } catch (error) { + if (!active) return; + logger.error('Error loading campaign assignments report:', error); + setRows([]); + setSummaryCards( + buildCampaignAssignmentsSummaryCards({ + totalAssignments: 0, + assignedStudents: totalStudents ?? 0, + activeStudents: 0, + averageAssignmentsCompletion: 0, + }), + ); + } finally { + if (active) setLoading(false); + } + }; + + void loadReport(); + return () => { + active = false; + }; + }, [campaignId, totalStudents]); + + const mobileRows = useMemo(() => rows, [rows]); + + return { + loading, + mobileRows, + rows, + summaryCards, + }; +}; diff --git a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.test.tsx b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.test.tsx new file mode 100644 index 0000000000..0c0336bdd6 --- /dev/null +++ b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.test.tsx @@ -0,0 +1,747 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import CampaignAssignmentsReport from './CampaignAssignmentsReport'; +import { + CAMPAIGN_ASSIGNMENTS_REPORT_SUBTAB, + buildCampaignAssignmentsSummaryCards, + mapAssignmentReportRows, +} from './CampaignAssignmentsReport.helpers'; +import { ServiceConfig } from '../../../services/ServiceConfig'; +import logger from '../../../utility/logger'; + +type AssignmentSummary = { + totalAssignments: number; + assignedStudents: number; + activeStudents: number; + averageAssignmentsCompletion: number; +}; + +type AssignmentRow = { + subjectId: string; + subjectName: string; + lessonsAssigned: number; + completionPercent: number; +}; + +type AssignmentReportResponse = { + summary: AssignmentSummary; + rows: AssignmentRow[]; +}; + +type DeferredReport = { + promise: Promise; + resolve: (value: AssignmentReportResponse) => void; + reject: (reason?: Error) => void; +}; + +type MockColumn = { + key: string; + label: string; +}; + +type MockTableRow = { + id: string; + subject: string; + lessonsAssigned: number; + completionPercent: string; +}; + +const mockUseMediaQuery = jest.fn< + boolean, + [string, { defaultMatches: boolean; noSsr: boolean }] +>(() => false); + +const mockDataTableBody = jest.fn< + React.ReactElement, + [ + { + columns: MockColumn[]; + rows: MockTableRow[]; + loading?: boolean; + }, + ] +>(); + +jest.mock('@mui/material', () => { + const actual = jest.requireActual('@mui/material'); + return { + ...actual, + useMediaQuery: ( + query: string, + options: { defaultMatches: boolean; noSsr: boolean }, + ) => mockUseMediaQuery(query, options), + }; +}); + +jest.mock('i18next', () => ({ + t: (key: string) => key, +})); + +jest.mock('../../../services/ServiceConfig', () => ({ + ServiceConfig: { + getI: jest.fn(), + }, +})); + +jest.mock('../../../utility/logger', () => ({ + __esModule: true, + default: { + error: jest.fn(), + }, +})); + +jest.mock('./CampaignsOverviewInfoTooltip', () => ({ + __esModule: true, + default: ({ label, message }: { label: string; message: string }) => ( + {message} + ), +})); + +jest.mock('../DataTableBody', () => ({ + __esModule: true, + default: (props: { + columns: MockColumn[]; + rows: MockTableRow[]; + loading?: boolean; + }) => { + mockDataTableBody(props); + return ( +
+ + {String(Boolean(props.loading))} + +
+ {props.columns.map((column) => ( + {column.label} + ))} +
+
+ {props.rows.map((row) => ( +
+ {row.subject} + {row.lessonsAssigned} + {row.completionPercent} +
+ ))} +
+
+ ); + }, +})); + +const apiHandler = { + getCampaignAssignmentsReport: jest.fn(), +}; + +const buildAssignmentRow = ( + overrides: Partial = {}, +): AssignmentRow => ({ + subjectId: 'subject-1', + subjectName: 'Mathematics', + lessonsAssigned: 23, + completionPercent: 57, + ...overrides, +}); + +const buildAssignmentResponse = ( + overrides: Partial = {}, +): AssignmentReportResponse => ({ + summary: { + totalAssignments: 120, + assignedStudents: 3942, + activeStudents: 2723, + averageAssignmentsCompletion: 38, + ...(overrides.summary ?? {}), + }, + rows: overrides.rows ?? [ + buildAssignmentRow(), + buildAssignmentRow({ + subjectId: 'subject-2', + subjectName: 'Science', + lessonsAssigned: 31, + completionPercent: 55, + }), + buildAssignmentRow({ + subjectId: 'subject-3', + subjectName: 'English', + lessonsAssigned: 23, + completionPercent: 26, + }), + buildAssignmentRow({ + subjectId: 'subject-4', + subjectName: 'Hindi', + lessonsAssigned: 36, + completionPercent: 42, + }), + ], +}); + +const createDeferredReport = (): DeferredReport => { + let resolve!: (value: AssignmentReportResponse) => void; + let reject!: (reason?: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const renderReport = (props?: { + campaignId?: string; + totalStudents?: number | null; +}) => + render( + , + ); + +beforeEach(() => { + jest.clearAllMocks(); + mockUseMediaQuery.mockReturnValue(false); + (ServiceConfig.getI as jest.Mock).mockReturnValue({ apiHandler }); + apiHandler.getCampaignAssignmentsReport.mockResolvedValue( + buildAssignmentResponse(), + ); +}); + +describe('CampaignAssignmentsReport helpers', () => { + it('exports the assignments subtab constant expected by the parent reports page', () => { + expect(CAMPAIGN_ASSIGNMENTS_REPORT_SUBTAB).toBe('Assignments'); + }); + + it('builds summary cards with labels, info text, and numeric formatting', () => { + const cards = buildCampaignAssignmentsSummaryCards({ + totalAssignments: 120, + assignedStudents: 3942, + activeStudents: 2723, + averageAssignmentsCompletion: 38, + }); + + expect(cards).toHaveLength(4); + expect(cards[0]).toEqual( + expect.objectContaining({ + key: 'totalAssignments', + label: 'Total Assignments', + value: '120', + }), + ); + expect(cards[1]).toEqual( + expect.objectContaining({ + key: 'assignedStudents', + label: 'Assigned Students', + value: '3,942', + }), + ); + expect(cards[2]).toEqual( + expect.objectContaining({ + key: 'activeStudents', + label: 'Active Students', + value: '2,723', + }), + ); + expect(cards[3]).toEqual( + expect.objectContaining({ + key: 'averageAssignmentsCompletion', + label: 'Average Assignments Completion', + value: '38%', + }), + ); + expect(cards[3].info).toBe( + 'Average number of assignments completed per active student during the selected period.', + ); + }); + + it('formats large average assignment completion values with locale separators and percent suffix', () => { + const cards = buildCampaignAssignmentsSummaryCards({ + totalAssignments: 450, + assignedStudents: 10987, + activeStudents: 2789, + averageAssignmentsCompletion: 1000, + }); + + const averageCard = cards.find( + (card) => card.key === 'averageAssignmentsCompletion', + ); + + expect(averageCard?.value).toBe('1,000%'); + }); + + it('keeps one decimal place for fractional average assignment completion values', () => { + const cards = buildCampaignAssignmentsSummaryCards({ + totalAssignments: 450, + assignedStudents: 10987, + activeStudents: 2789, + averageAssignmentsCompletion: 1000.4, + }); + + const averageCard = cards.find( + (card) => card.key === 'averageAssignmentsCompletion', + ); + + expect(averageCard?.value).toBe('1,000.4%'); + }); + + it('maps assignment rows into table rows and rounds subject completion percentages', () => { + const rows = mapAssignmentReportRows([ + buildAssignmentRow({ + subjectId: 'subject-1', + subjectName: 'Mathematics', + lessonsAssigned: 14, + completionPercent: 57.2, + }), + buildAssignmentRow({ + subjectId: 'subject-2', + subjectName: 'Science', + lessonsAssigned: 18, + completionPercent: 55.9, + }), + ]); + + expect(rows).toEqual([ + { + id: 'subject-1', + subject: 'Mathematics', + lessonsAssigned: 14, + completionPercent: '57%', + }, + { + id: 'subject-2', + subject: 'Science', + lessonsAssigned: 18, + completionPercent: '56%', + }, + ]); + }); +}); + +describe('CampaignAssignmentsReport desktop rendering', () => { + it('renders the assignment report title, widgets, tooltips, and desktop rows', async () => { + renderReport({ + campaignId: 'campaign-1', + totalStudents: 3942, + }); + + expect(await screen.findByText('Assignment Report')).toBeInTheDocument(); + expect(screen.getByText('Total Assignments')).toBeInTheDocument(); + expect(screen.getByText('Assigned Students')).toBeInTheDocument(); + expect(screen.getByText('Active Students')).toBeInTheDocument(); + expect( + screen.getByText('Average Assignments Completion'), + ).toBeInTheDocument(); + expect(screen.getByText('120')).toBeInTheDocument(); + expect(screen.getByText('3,942')).toBeInTheDocument(); + expect(screen.getByText('2,723')).toBeInTheDocument(); + expect(screen.getByText('38%')).toBeInTheDocument(); + expect(screen.getByTestId('tooltip-Total-Assignments')).toHaveTextContent( + 'Total number of assignments assigned through the campaign while creating the campaign.', + ); + expect(screen.getByText('Mathematics')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByText('Hindi')).toBeInTheDocument(); + expect(screen.getByText('57%')).toBeInTheDocument(); + expect(screen.getByText('55%')).toBeInTheDocument(); + expect(screen.getByText('26%')).toBeInTheDocument(); + expect(screen.getByText('42%')).toBeInTheDocument(); + expect(apiHandler.getCampaignAssignmentsReport).toHaveBeenCalledWith( + 'campaign-1', + { totalStudents: 3942 }, + ); + expect(mockDataTableBody.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('passes the expected columns into DataTableBody for the desktop table', async () => { + renderReport({ + campaignId: 'campaign-1', + totalStudents: 3942, + }); + + await screen.findByText('Assignment Report'); + + const latestCall = mockDataTableBody.mock.calls.at(-1); + const props = latestCall?.[0]; + + expect(props?.columns).toEqual([ + { + key: 'subject', + label: 'Subject', + sortable: false, + width: '40%', + }, + { + key: 'lessonsAssigned', + label: 'Lessons Assigned', + sortable: false, + width: '30%', + }, + { + key: 'completionPercent', + label: 'Completion %', + sortable: false, + width: '30%', + }, + ]); + expect(props?.rows).toEqual([ + { + id: 'subject-1', + subject: 'Mathematics', + lessonsAssigned: 23, + completionPercent: '57%', + }, + { + id: 'subject-2', + subject: 'Science', + lessonsAssigned: 31, + completionPercent: '55%', + }, + { + id: 'subject-3', + subject: 'English', + lessonsAssigned: 23, + completionPercent: '26%', + }, + { + id: 'subject-4', + subject: 'Hindi', + lessonsAssigned: 36, + completionPercent: '42%', + }, + ]); + }); + + it('renders very large average assignment completion values with separators and percent suffix', async () => { + apiHandler.getCampaignAssignmentsReport.mockResolvedValueOnce( + buildAssignmentResponse({ + summary: { + totalAssignments: 500, + assignedStudents: 25000, + activeStudents: 2500, + averageAssignmentsCompletion: 1000, + }, + }), + ); + + renderReport({ + campaignId: 'campaign-2', + totalStudents: 25000, + }); + + expect(await screen.findByText('1,000%')).toBeInTheDocument(); + }); + + it('shows loading true in the shared table before the API resolves', async () => { + const deferred = createDeferredReport(); + apiHandler.getCampaignAssignmentsReport.mockReturnValueOnce( + deferred.promise, + ); + + renderReport({ + campaignId: 'campaign-loading', + totalStudents: 100, + }); + + await waitFor(() => + expect( + screen.getByTestId('assignments-data-table-loading'), + ).toHaveTextContent('true'), + ); + + deferred.resolve(buildAssignmentResponse()); + + await waitFor(() => + expect( + screen.getByTestId('assignments-data-table-loading'), + ).toHaveTextContent('false'), + ); + }); + + it('renders fallback summary values when the API request fails', async () => { + apiHandler.getCampaignAssignmentsReport.mockRejectedValueOnce( + new Error('report failed'), + ); + + renderReport({ + campaignId: 'campaign-error', + totalStudents: 900, + }); + + expect(await screen.findByText('Assignment Report')).toBeInTheDocument(); + expect(screen.getByText('900')).toBeInTheDocument(); + expect(screen.getAllByText('0').length).toBeGreaterThan(0); + expect(logger.error).toHaveBeenCalled(); + }); + + it('does not call the API when the campaign id is missing and shows default zeros', () => { + renderReport({ + campaignId: undefined, + totalStudents: 888, + }); + + expect(screen.getByText('Assignment Report')).toBeInTheDocument(); + expect(screen.getAllByText('0').length).toBeGreaterThan(0); + expect(screen.getByText('0%')).toBeInTheDocument(); + expect(apiHandler.getCampaignAssignmentsReport).not.toHaveBeenCalled(); + }); + + it('re-fetches the report when totalStudents changes because assignedStudents comes from the overview page', async () => { + const { rerender } = render( + , + ); + + await screen.findByText('Assignment Report'); + + rerender( + , + ); + + await waitFor(() => + expect(apiHandler.getCampaignAssignmentsReport).toHaveBeenLastCalledWith( + 'campaign-1', + { totalStudents: 200 }, + ), + ); + expect(apiHandler.getCampaignAssignmentsReport).toHaveBeenCalledTimes(2); + }); +}); + +describe('CampaignAssignmentsReport mobile rendering', () => { + it('renders mobile cards instead of the shared table when the media query matches', async () => { + mockUseMediaQuery.mockReturnValue(true); + + renderReport({ + campaignId: 'campaign-mobile', + totalStudents: 3942, + }); + + expect(await screen.findByText('Assignment Report')).toBeInTheDocument(); + expect(screen.getByText('Mathematics')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByText('Hindi')).toBeInTheDocument(); + expect(screen.getAllByText('Lessons Assigned').length).toBeGreaterThan(0); + expect(screen.getAllByText('Completion %').length).toBeGreaterThan(0); + expect( + screen.queryByTestId('assignments-data-table'), + ).not.toBeInTheDocument(); + expect(mockDataTableBody).not.toHaveBeenCalled(); + }); + + it('still renders the formatted average widget value on mobile', async () => { + mockUseMediaQuery.mockReturnValue(true); + apiHandler.getCampaignAssignmentsReport.mockResolvedValueOnce( + buildAssignmentResponse({ + summary: { + totalAssignments: 100, + assignedStudents: 3000, + activeStudents: 400, + averageAssignmentsCompletion: 1000.4, + }, + }), + ); + + renderReport({ + campaignId: 'campaign-mobile-format', + totalStudents: 3000, + }); + + expect(await screen.findByText('1,000.4%')).toBeInTheDocument(); + }); +}); + +describe('CampaignAssignmentsReport data contract coverage', () => { + it('sends zero totalStudents when the parent passes null so the API gets a stable numeric payload', async () => { + renderReport({ + campaignId: 'campaign-null-students', + totalStudents: null, + }); + + await screen.findByText('Assignment Report'); + + expect(apiHandler.getCampaignAssignmentsReport).toHaveBeenCalledWith( + 'campaign-null-students', + { totalStudents: 0 }, + ); + }); + + it('renders an empty rows state cleanly when the API returns no subject rows', async () => { + apiHandler.getCampaignAssignmentsReport.mockResolvedValueOnce( + buildAssignmentResponse({ + summary: { + totalAssignments: 0, + assignedStudents: 50, + activeStudents: 0, + averageAssignmentsCompletion: 0, + }, + rows: [], + }), + ); + + renderReport({ + campaignId: 'campaign-empty', + totalStudents: 50, + }); + + expect(await screen.findByText('Assignment Report')).toBeInTheDocument(); + expect(screen.getByText('50')).toBeInTheDocument(); + expect(screen.getByText('0%')).toBeInTheDocument(); + + const latestCall = mockDataTableBody.mock.calls.at(-1); + expect(latestCall?.[0].rows).toEqual([]); + }); + + it('keeps row ordering exactly as provided by the API layer because sorting is owned by the backend payload', async () => { + apiHandler.getCampaignAssignmentsReport.mockResolvedValueOnce( + buildAssignmentResponse({ + rows: [ + buildAssignmentRow({ + subjectId: 'subject-10', + subjectName: 'Zulu', + lessonsAssigned: 4, + completionPercent: 12, + }), + buildAssignmentRow({ + subjectId: 'subject-11', + subjectName: 'Alpha', + lessonsAssigned: 6, + completionPercent: 18, + }), + ], + }), + ); + + renderReport({ + campaignId: 'campaign-ordered', + totalStudents: 80, + }); + + expect(await screen.findByText('Zulu')).toBeInTheDocument(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + + const latestCall = mockDataTableBody.mock.calls.at(-1); + expect(latestCall?.[0].rows).toEqual([ + { + id: 'subject-10', + subject: 'Zulu', + lessonsAssigned: 4, + completionPercent: '12%', + }, + { + id: 'subject-11', + subject: 'Alpha', + lessonsAssigned: 6, + completionPercent: '18%', + }, + ]); + }); + + it('renders all tooltip copy so report help text remains attached to the cards', async () => { + renderReport({ + campaignId: 'campaign-tooltips', + totalStudents: 3942, + }); + + await screen.findByText('Assignment Report'); + + expect(screen.getByTestId('tooltip-Total-Assignments')).toHaveTextContent( + 'Total number of assignments assigned through the campaign while creating the campaign.', + ); + expect(screen.getByTestId('tooltip-Assigned-Students')).toHaveTextContent( + 'Total number of students who received at least one assignment through the campaign.', + ); + expect(screen.getByTestId('tooltip-Active-Students')).toHaveTextContent( + 'Total number of students who completed at least one assignment during the campaign period.', + ); + expect( + screen.getByTestId('tooltip-Average-Assignments-Completion'), + ).toHaveTextContent( + 'Average number of assignments completed per active student during the selected period.', + ); + }); + + it('renders a zero-percent average widget on default state even though the metric can exceed one hundred later', () => { + renderReport({ + campaignId: undefined, + totalStudents: undefined, + }); + + expect(screen.getByText('0%')).toBeInTheDocument(); + }); + + it('supports four-digit assigned student formatting independently from the average widget formatting', async () => { + apiHandler.getCampaignAssignmentsReport.mockResolvedValueOnce( + buildAssignmentResponse({ + summary: { + totalAssignments: 24, + assignedStudents: 1000, + activeStudents: 250, + averageAssignmentsCompletion: 123.4, + }, + }), + ); + + renderReport({ + campaignId: 'campaign-thousand-students', + totalStudents: 1000, + }); + + expect(await screen.findByText('1,000')).toBeInTheDocument(); + expect(screen.getByText('123.4%')).toBeInTheDocument(); + }); + + it('keeps the assignment report shell visible while data is being loaded so layout does not jump', async () => { + const deferred = createDeferredReport(); + apiHandler.getCampaignAssignmentsReport.mockReturnValueOnce( + deferred.promise, + ); + + renderReport({ + campaignId: 'campaign-shell', + totalStudents: 20, + }); + + expect(screen.getByText('Assignment Report')).toBeInTheDocument(); + expect( + screen.getByTestId('assignments-data-table-loading'), + ).toHaveTextContent('true'); + + deferred.resolve(buildAssignmentResponse()); + + await screen.findByText('Mathematics'); + }); + + it('maps subject completion percentages using the helper formatter before they reach the table', async () => { + apiHandler.getCampaignAssignmentsReport.mockResolvedValueOnce( + buildAssignmentResponse({ + rows: [ + buildAssignmentRow({ + subjectId: 'subject-99', + subjectName: 'Art', + lessonsAssigned: 3, + completionPercent: 49.6, + }), + ], + }), + ); + + renderReport({ + campaignId: 'campaign-rounding', + totalStudents: 10, + }); + + expect(await screen.findByText('Art')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + + const latestCall = mockDataTableBody.mock.calls.at(-1); + expect(latestCall?.[0].rows).toEqual([ + { + id: 'subject-99', + subject: 'Art', + lessonsAssigned: 3, + completionPercent: '50%', + }, + ]); + }); +}); diff --git a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx new file mode 100644 index 0000000000..04a457a6bf --- /dev/null +++ b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx @@ -0,0 +1,154 @@ +import React, { useMemo } from 'react'; +import { Box, Typography, useMediaQuery } from '@mui/material'; +import { t } from 'i18next'; +import CampaignsOverviewInfoTooltip from './CampaignsOverviewInfoTooltip'; +import DataTableBody, { type Column } from '../DataTableBody'; +import type { CampaignAssignmentsTableRow } from './CampaignAssignmentsReport.helpers'; +import { useCampaignAssignmentsReportState } from './CampaignAssignmentsReport.helpers'; + +const cardStyles = { + border: '1px solid #DDE1E6', + borderRadius: '8px', + background: '#F8FAFD', + minHeight: 86, + px: 1.5, + py: 1.25, +} as const; + +type CampaignAssignmentsReportProps = { + campaignId?: string; + totalStudents?: number | null; +}; + +const CampaignAssignmentsReport: React.FC = ({ + campaignId, + totalStudents, +}) => { + const isMobile = useMediaQuery('(max-width:600px)', { + defaultMatches: false, + noSsr: true, + }); + const report = useCampaignAssignmentsReportState(campaignId, totalStudents); + const columns = useMemo[]>( + () => [ + { key: 'subject', label: t('Subject'), sortable: false, width: '40%' }, + { + key: 'lessonsAssigned', + label: t('Lessons Assigned'), + sortable: false, + width: '30%', + }, + { + key: 'completionPercent', + label: t('Completion %'), + sortable: false, + width: '30%', + }, + ], + [], + ); + + return ( + + + + {t('Assignment Report')} + + + + + + {report.summaryCards.map((card) => ( + + + + {card.label} + + + + + {card.value} + + + ))} + + + {isMobile ? ( + + {report.mobileRows.map((row) => ( + + + {row.subject} + + + + {row.lessonsAssigned} + + + {t('Lessons Assigned')} + + + + + {row.completionPercent} + + + {t('Completion %')} + + + + ))} + + ) : ( + + undefined} + loading={report.loading} + disableRowNavigation + tableWidth="100%" + headerNoEllipsis + /> + + )} + + + ); +}; + +export default CampaignAssignmentsReport; diff --git a/src/ops-console/components/campaignsOverview/CampaignRewardsReport.helpers.ts b/src/ops-console/components/campaignsOverview/CampaignRewardsReport.helpers.ts index ce88b874a2..2058cdfea8 100644 --- a/src/ops-console/components/campaignsOverview/CampaignRewardsReport.helpers.ts +++ b/src/ops-console/components/campaignsOverview/CampaignRewardsReport.helpers.ts @@ -50,6 +50,13 @@ export const CAMPAIGN_REPORT_SUBTABS = [ 'Rewards', ] as const; +export const CAMPAIGN_REPORT_SUBTAB_KEYS = { + SCHOOL_PERFORMANCE: CAMPAIGN_REPORT_SUBTABS[0], + ASSIGNMENTS: CAMPAIGN_REPORT_SUBTABS[1], + MESSAGES: CAMPAIGN_REPORT_SUBTABS[2], + REWARDS: CAMPAIGN_REPORT_SUBTABS[3], +} as const; + export const CAMPAIGN_REWARD_PAGE_SIZE = 10; export const CAMPAIGN_REWARD_EXPORT_FILE_NAME = 'CampaignRewards.xlsx'; const CAMPAIGN_REWARD_EXPORT_SHEET_NAME = 'Campaign Rewards'; diff --git a/src/ops-console/components/campaignsOverview/CampaignRewardsReport.test.tsx b/src/ops-console/components/campaignsOverview/CampaignRewardsReport.test.tsx index bf1740697d..8e6c6a6550 100644 --- a/src/ops-console/components/campaignsOverview/CampaignRewardsReport.test.tsx +++ b/src/ops-console/components/campaignsOverview/CampaignRewardsReport.test.tsx @@ -14,7 +14,15 @@ import CampaignRewardsTable, { import type { CampaignRewardRow } from './CampaignRewardsReport.helpers'; import { ServiceConfig } from '../../../services/ServiceConfig'; -const mockDownload = jest.fn(); +const mockDownload = jest.fn<(blob: Blob, fileName: string) => void>(); + +jest.mock('@mui/material', () => { + const actual = jest.requireActual('@mui/material'); + return { + ...actual, + useMediaQuery: jest.fn(() => false), + }; +}); jest.mock('i18next', () => ({ t: (key: string) => key, @@ -28,10 +36,15 @@ jest.mock('../../../services/ServiceConfig', () => ({ jest.mock('../../../utility/util', () => ({ Util: { - handleBlobDownloadAndSave: (...args: unknown[]) => mockDownload(...args), + handleBlobDownloadAndSave: (...args: [Blob, string]) => + mockDownload(...args), }, })); +type MockTableRow = CampaignRewardRow & { + [key: string]: React.ReactNode | string | number | undefined; +}; + jest.mock('../DataTableBody', () => ({ __esModule: true, default: ({ @@ -45,9 +58,9 @@ jest.mock('../DataTableBody', () => ({ columns: Array<{ key: string; label: string; - render?: (row: CampaignRewardRow) => React.ReactNode; + render?: (row: MockTableRow) => React.ReactNode; }>; - rows: CampaignRewardRow[]; + rows: MockTableRow[]; loading: boolean; onSort: (key: string) => void; order: 'asc' | 'desc'; @@ -66,13 +79,10 @@ jest.mock('../DataTableBody', () => ({ ))} {rows.map((row) => ( -
- {row.studentName} - {row.school} - {row.className} - {row.rewardLabel} - {columnText(columns, 'completionPercent', row)} - {columnText(columns, 'rewardRank', row)} +
+ {columns.map((column) => ( + {columnText(columns, column.key, row)} + ))}
))}
@@ -99,16 +109,16 @@ jest.mock('../DataTablePagination', () => ({ const columnText = ( columns: Array<{ key: string; - render?: (row: CampaignRewardRow) => React.ReactNode; + render?: (row: MockTableRow) => React.ReactNode; }>, key: string, - row: CampaignRewardRow, + row: MockTableRow, ) => { const rendered = columns.find((column) => column.key === key)?.render?.(row); if (React.isValidElement(rendered)) { return rendered.props.label ?? rendered.props.children; } - return rendered ?? ''; + return rendered ?? row[key] ?? ''; }; const buildRewardRow = ( @@ -128,6 +138,7 @@ const buildRewardRow = ( }); const apiHandler = { + getCampaignAssignmentsReport: jest.fn(), getCampaignRewardsReport: jest.fn(), }; @@ -191,6 +202,28 @@ describe('Campaign rewards TSX components', () => { ], total: 3, }); + apiHandler.getCampaignAssignmentsReport.mockResolvedValue({ + summary: { + totalAssignments: 120, + assignedStudents: 3942, + activeStudents: 2723, + averageAssignmentsCompletion: 38, + }, + rows: [ + { + subjectId: 'subject-1', + subjectName: 'Mathematics', + lessonsAssigned: 23, + completionPercent: 57, + }, + { + subjectId: 'subject-2', + subjectName: 'Science', + lessonsAssigned: 31, + completionPercent: 55, + }, + ], + }); }); it('renders summary cards with counts, percentages, and labels', () => { @@ -322,4 +355,30 @@ describe('Campaign rewards TSX components', () => { expect(screen.getByDisplayValue('All Classes')).toBeInTheDocument(); expect(apiHandler.getCampaignRewardsReport).toHaveBeenCalledTimes(1); }); + + it('renders the assignments report subtab with summary cards and subject rows', async () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Assignments' })); + + expect(await screen.findByText('Assignment Report')).toBeInTheDocument(); + expect(screen.getByText('Total Assignments')).toBeInTheDocument(); + expect(screen.getByText('Assigned Students')).toBeInTheDocument(); + expect(screen.getByText('Active Students')).toBeInTheDocument(); + expect( + screen.getByText('Average Assignments Completion'), + ).toBeInTheDocument(); + expect(screen.getByText('Mathematics')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + expect(apiHandler.getCampaignAssignmentsReport).toHaveBeenCalledWith( + 'campaign-1', + { totalStudents: 3942 }, + ); + }); }); diff --git a/src/ops-console/components/campaignsOverview/CampaignRewardsReport.tsx b/src/ops-console/components/campaignsOverview/CampaignRewardsReport.tsx index 0492b2c7b7..318483ff17 100644 --- a/src/ops-console/components/campaignsOverview/CampaignRewardsReport.tsx +++ b/src/ops-console/components/campaignsOverview/CampaignRewardsReport.tsx @@ -1,11 +1,13 @@ import React from 'react'; import { Box, Button, Typography } from '@mui/material'; import { t } from 'i18next'; +import CampaignAssignmentsReport from './CampaignAssignmentsReport'; import CampaignRewardsSummaryCards from './CampaignRewardsSummaryCards'; import CampaignRewardsTable, { CampaignRewardsReportHeader, } from './CampaignRewardsTable'; import { + CAMPAIGN_REPORT_SUBTAB_KEYS, CAMPAIGN_REPORT_SUBTABS, useCampaignRewardsReportState, } from './CampaignRewardsReport.helpers'; @@ -14,11 +16,13 @@ import type { CampaignRewardsPayload } from '../../../services/api/ServiceApi'; interface CampaignRewardsReportProps { campaignId?: string; rewards?: string | CampaignRewardsPayload | null; + totalStudents?: number | null; } const CampaignRewardsReport: React.FC = ({ campaignId, rewards, + totalStudents, }) => { const report = useCampaignRewardsReportState(campaignId, rewards); @@ -55,7 +59,12 @@ const CampaignRewardsReport: React.FC = ({ ))} - {report.selectedSubtab !== 'Rewards' ? ( + {report.selectedSubtab === CAMPAIGN_REPORT_SUBTAB_KEYS.ASSIGNMENTS ? ( + + ) : report.selectedSubtab !== CAMPAIGN_REPORT_SUBTAB_KEYS.REWARDS ? ( = ({ )} diff --git a/src/services/api/ApiHandler.ts b/src/services/api/ApiHandler.ts index 0c4e862efc..f1a942430b 100644 --- a/src/services/api/ApiHandler.ts +++ b/src/services/api/ApiHandler.ts @@ -24,6 +24,8 @@ import { SchoolProgramAccessResponse, ServiceApi, CampaignAssignmentsResponse, + CampaignAssignmentsReportParams, + CampaignAssignmentsReportResponse, CampaignRewardsReportParams, CampaignRewardsReportResponse, CampaignOption, @@ -1674,6 +1676,13 @@ export class ApiHandler implements ServiceApi { return await this.s.getCampaignAssignments(campaignId, filters); } + public async getCampaignAssignmentsReport( + campaignId: string, + params?: CampaignAssignmentsReportParams, + ): Promise { + return await this.s.getCampaignAssignmentsReport(campaignId, params); + } + public async getCampaignRewardsReport( campaignId: string, params?: CampaignRewardsReportParams, diff --git a/src/services/api/ServiceApi.ts b/src/services/api/ServiceApi.ts index 4ff7db4665..fd15a58eeb 100644 --- a/src/services/api/ServiceApi.ts +++ b/src/services/api/ServiceApi.ts @@ -467,6 +467,27 @@ export type CampaignAssignmentsResponse = { total: number; }; +export type CampaignAssignmentsReportParams = { + totalStudents?: number; +}; + +export type CampaignAssignmentsReportRow = { + subjectId: string; + subjectName: string; + lessonsAssigned: number; + completionPercent: number; +}; + +export type CampaignAssignmentsReportResponse = { + summary: { + totalAssignments: number; + assignedStudents: number; + activeStudents: number; + averageAssignmentsCompletion: number; + }; + rows: CampaignAssignmentsReportRow[]; +}; + export type CampaignStudentPerformanceRow = TableTypes<'campaign_student_performance'>; @@ -2525,6 +2546,20 @@ export interface ServiceApi { filters: CampaignAssignmentFilters, ): Promise; + /** + * Fetches campaign assignment summary widgets and subject-wise completion rows. + * The optional totalStudents value is forwarded to the backend so assigned-student + * counts can stay aligned with the campaign overview source of truth. + */ + getCampaignAssignmentsReport( + campaignId: string, + params?: CampaignAssignmentsReportParams, + ): Promise; + + /** + * Fetches campaign student reward-performance rows for the Rewards report subtab. + * Supports optional client-side filters that are translated into the underlying query. + */ getCampaignRewardsReport( campaignId: string, params?: CampaignRewardsReportParams, diff --git a/src/services/api/sqlite/SqliteApi.campaign.ts b/src/services/api/sqlite/SqliteApi.campaign.ts index 487467b623..99897f68d0 100644 --- a/src/services/api/sqlite/SqliteApi.campaign.ts +++ b/src/services/api/sqlite/SqliteApi.campaign.ts @@ -5,6 +5,8 @@ import { CampaignAssignmentOptions, CampaignAssignmentOptionsParams, CampaignAssignmentsResponse, + CampaignAssignmentsReportParams, + CampaignAssignmentsReportResponse, CampaignAudienceOptions, CampaignAudiencePayload, CampaignAudienceSummary, @@ -159,4 +161,14 @@ export class SqliteApiCampaign extends SqliteApiAssignment { ): Promise { return await this._serverApi.getCampaignRewardsReport(campaignId, params); } + + async getCampaignAssignmentsReport( + campaignId: string, + params?: CampaignAssignmentsReportParams, + ): Promise { + return await this._serverApi.getCampaignAssignmentsReport( + campaignId, + params, + ); + } } diff --git a/src/services/api/supabase/SupabaseApi.campaign.ts b/src/services/api/supabase/SupabaseApi.campaign.ts index 0a7acf19d6..80ba453dd3 100644 --- a/src/services/api/supabase/SupabaseApi.campaign.ts +++ b/src/services/api/supabase/SupabaseApi.campaign.ts @@ -17,6 +17,8 @@ import { CampaignAssignmentOptions, CampaignAssignmentOptionsParams, CampaignAssignmentsResponse, + CampaignAssignmentsReportParams, + CampaignAssignmentsReportResponse, CampaignAudienceOptions, CampaignAudiencePayload, CampaignAudienceSummary, @@ -1906,6 +1908,66 @@ export class SupabaseApiCampaign extends SupabaseApiOps { }; } + async getCampaignAssignmentsReport( + campaignId: string, + params: CampaignAssignmentsReportParams = {}, + ): Promise { + const emptyResponse: CampaignAssignmentsReportResponse = { + summary: { + totalAssignments: 0, + assignedStudents: params.totalStudents ?? 0, + activeStudents: 0, + averageAssignmentsCompletion: 0, + }, + rows: [], + }; + + if (!this.supabase || !campaignId) { + return emptyResponse; + } + + try { + const { data, error } = await this.supabase.rpc( + 'get_campaign_assignments_report', + { + p_campaign_id: campaignId, + p_total_students: params.totalStudents ?? 0, + }, + ); + + if (error) { + logger.error('Error fetching campaign assignments report rpc:', { + campaignId, + params, + error, + }); + return emptyResponse; + } + + const payload = + data as Database['public']['Functions']['get_campaign_assignments_report']['Returns']; + + return { + summary: { + totalAssignments: payload.summary?.totalAssignments ?? 0, + assignedStudents: + payload.summary?.assignedStudents ?? params.totalStudents ?? 0, + activeStudents: payload.summary?.activeStudents ?? 0, + averageAssignmentsCompletion: + payload.summary?.averageAssignmentsCompletion ?? 0, + }, + rows: payload.rows ?? [], + }; + } catch (error) { + logger.error('Exception fetching campaign assignments report rpc:', { + campaignId, + params, + error, + }); + return emptyResponse; + } + } + private async fetchDistinctClassGradesForSchools( schoolIds: string[], ): Promise<{ id: string; name: string }[]> { diff --git a/src/services/database.ts b/src/services/database.ts index 2ff3f6359f..4bc42369dd 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -7968,6 +7968,26 @@ export type Database = { total_count: number; }[]; }; + get_campaign_assignments_report: { + Args: { + p_campaign_id: string; + p_total_students?: number | null; + }; + Returns: { + rows: { + completionPercent: number; + lessonsAssigned: number; + subjectId: string; + subjectName: string; + }[]; + summary: { + activeStudents: number; + assignedStudents: number; + averageAssignmentsCompletion: number; + totalAssignments: number; + }; + }; + }; update_class_firebase_trigger: { Args: { p_course_firebase_ids: string[]; From 04455a8e29488606a054d65242fc78b93ce795e1 Mon Sep 17 00:00:00 2001 From: Renuka <133939590+renukaj07@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:10:26 +0530 Subject: [PATCH 2/3] added fallback values --- .../CampaignAssignmentsReport.helpers.ts | 78 +++++++++---------- .../CampaignAssignmentsReport.tsx | 2 +- 2 files changed, 39 insertions(+), 41 deletions(-) diff --git a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts index c8831a81b4..84dc22375a 100644 --- a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts +++ b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.helpers.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { t } from 'i18next'; import { ServiceConfig } from '../../../services/ServiceConfig'; +import type { CampaignAssignmentsReportResponse } from '../../../services/api/ServiceApi'; import logger from '../../../utility/logger'; export type CampaignAssignmentsSummaryCard = { @@ -33,7 +34,7 @@ export const buildCampaignAssignmentsSummaryCards = ({ { key: 'totalAssignments', label: t('Total Assignments'), - value: String(totalAssignments), + value: String(totalAssignments ?? 0), info: t( 'Total number of assignments assigned through the campaign while creating the campaign.', ), @@ -41,7 +42,7 @@ export const buildCampaignAssignmentsSummaryCards = ({ { key: 'assignedStudents', label: t('Assigned Students'), - value: assignedStudents.toLocaleString('en-IN'), + value: (assignedStudents ?? 0).toLocaleString('en-IN'), info: t( 'Total number of students who received at least one assignment through the campaign.', ), @@ -49,7 +50,7 @@ export const buildCampaignAssignmentsSummaryCards = ({ { key: 'activeStudents', label: t('Active Students'), - value: activeStudents.toLocaleString('en-IN'), + value: (activeStudents ?? 0).toLocaleString('en-IN'), info: t( 'Total number of students who completed at least one assignment during the campaign period.', ), @@ -57,7 +58,7 @@ export const buildCampaignAssignmentsSummaryCards = ({ { key: 'averageAssignmentsCompletion', label: t('Average Assignments Completion'), - value: `${averageAssignmentsCompletion.toLocaleString('en-IN', { + value: `${(averageAssignmentsCompletion ?? 0).toLocaleString('en-IN', { maximumFractionDigits: 1, })}%`, info: t( @@ -77,8 +78,8 @@ export const mapAssignmentReportRows = ( rows.map((row) => ({ id: row.subjectId, subject: row.subjectName, - lessonsAssigned: row.lessonsAssigned, - completionPercent: `${Math.round(row.completionPercent)}%`, + lessonsAssigned: row.lessonsAssigned ?? 0, + completionPercent: `${Math.round(row.completionPercent ?? 0)}%`, })); export const useCampaignAssignmentsReportState = ( @@ -86,25 +87,15 @@ export const useCampaignAssignmentsReportState = ( totalStudents?: number | null, ) => { const [loading, setLoading] = useState(false); - const [rows, setRows] = useState([]); - const [summaryCards, setSummaryCards] = useState< - CampaignAssignmentsSummaryCard[] - >([]); + const [reportData, setReportData] = + useState(null); useEffect(() => { let active = true; const loadReport = async () => { if (!campaignId) { - setRows([]); - setSummaryCards( - buildCampaignAssignmentsSummaryCards({ - totalAssignments: 0, - assignedStudents: 0, - activeStudents: 0, - averageAssignmentsCompletion: 0, - }), - ); + setReportData(null); return; } @@ -116,28 +107,11 @@ export const useCampaignAssignmentsReportState = ( { totalStudents: totalStudents ?? 0 }, ); if (!active) return; - setRows(mapAssignmentReportRows(response.rows)); - setSummaryCards( - buildCampaignAssignmentsSummaryCards({ - totalAssignments: response.summary.totalAssignments, - assignedStudents: response.summary.assignedStudents, - activeStudents: response.summary.activeStudents, - averageAssignmentsCompletion: - response.summary.averageAssignmentsCompletion, - }), - ); + setReportData(response); } catch (error) { if (!active) return; logger.error('Error loading campaign assignments report:', error); - setRows([]); - setSummaryCards( - buildCampaignAssignmentsSummaryCards({ - totalAssignments: 0, - assignedStudents: totalStudents ?? 0, - activeStudents: 0, - averageAssignmentsCompletion: 0, - }), - ); + setReportData(null); } finally { if (active) setLoading(false); } @@ -149,11 +123,35 @@ export const useCampaignAssignmentsReportState = ( }; }, [campaignId, totalStudents]); - const mobileRows = useMemo(() => rows, [rows]); + const summaryCards = useMemo(() => { + if (!reportData) { + return buildCampaignAssignmentsSummaryCards({ + totalAssignments: 0, + assignedStudents: totalStudents ?? 0, + activeStudents: 0, + averageAssignmentsCompletion: 0, + }); + } + + return buildCampaignAssignmentsSummaryCards({ + totalAssignments: reportData.summary.totalAssignments, + assignedStudents: reportData.summary.assignedStudents, + activeStudents: reportData.summary.activeStudents, + averageAssignmentsCompletion: + reportData.summary.averageAssignmentsCompletion, + }); + }, [reportData, totalStudents]); + + const rows = useMemo(() => { + if (!reportData) { + return []; + } + + return mapAssignmentReportRows(reportData.rows); + }, [reportData]); return { loading, - mobileRows, rows, summaryCards, }; diff --git a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx index 04a457a6bf..5e6e7559c6 100644 --- a/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx +++ b/src/ops-console/components/campaignsOverview/CampaignAssignmentsReport.tsx @@ -97,7 +97,7 @@ const CampaignAssignmentsReport: React.FC = ({ {isMobile ? ( - {report.mobileRows.map((row) => ( + {report.rows.map((row) => ( Date: Mon, 20 Jul 2026 17:10:44 +0530 Subject: [PATCH 3/3] Update SupabaseApi.campaign.ts --- src/services/api/supabase/SupabaseApi.campaign.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/api/supabase/SupabaseApi.campaign.ts b/src/services/api/supabase/SupabaseApi.campaign.ts index 80ba453dd3..fff521bbea 100644 --- a/src/services/api/supabase/SupabaseApi.campaign.ts +++ b/src/services/api/supabase/SupabaseApi.campaign.ts @@ -1949,14 +1949,14 @@ export class SupabaseApiCampaign extends SupabaseApiOps { return { summary: { - totalAssignments: payload.summary?.totalAssignments ?? 0, + totalAssignments: payload?.summary?.totalAssignments ?? 0, assignedStudents: - payload.summary?.assignedStudents ?? params.totalStudents ?? 0, - activeStudents: payload.summary?.activeStudents ?? 0, + payload?.summary?.assignedStudents ?? params.totalStudents ?? 0, + activeStudents: payload?.summary?.activeStudents ?? 0, averageAssignmentsCompletion: - payload.summary?.averageAssignmentsCompletion ?? 0, + payload?.summary?.averageAssignmentsCompletion ?? 0, }, - rows: payload.rows ?? [], + rows: payload?.rows ?? [], }; } catch (error) { logger.error('Exception fetching campaign assignments report rpc:', {