diff --git a/src/components/ProfileMenu/ProfileMenu.test.tsx b/src/components/ProfileMenu/ProfileMenu.test.tsx index 6b4937c27f..e1a6c72aac 100644 --- a/src/components/ProfileMenu/ProfileMenu.test.tsx +++ b/src/components/ProfileMenu/ProfileMenu.test.tsx @@ -7,11 +7,14 @@ import { Util } from '../../utility/util'; import { useAppSelector } from '../../redux/hooks'; import { MemoryRouter } from 'react-router-dom'; import { + CURRENT_MODE, EVENTS, + MODES, PAGES, STICKER_BOOK_NOTIFICATION_DOT_ENABLED, ENABLE_STICKER_BOOK, } from '../../common/constants'; +import { schoolUtil } from '../../utility/schoolUtil'; // --- Mocks --- @@ -36,15 +39,22 @@ jest.mock('../../i18n', () => ({ })); const mockPush = jest.fn(); +const mockReplace = jest.fn(); jest.mock('react-router', () => ({ ...jest.requireActual('react-router'), useHistory: () => ({ push: mockPush, - replace: jest.fn(), + replace: mockReplace, location: { pathname: '/' }, }), })); +jest.mock('../../utility/schoolUtil', () => ({ + schoolUtil: { + setCurrentClass: jest.fn(), + }, +})); + // Mock useGbContext jest.mock('../../growthbook/Growthbook', () => ({ useGbContext: () => ({ setGbUpdated: jest.fn() }), @@ -60,10 +70,12 @@ const mockApi = { }; const mockStudent = { id: 'student-123', name: 'Test Student' }; +const mockSetCurrentClass = schoolUtil.setCurrentClass as jest.Mock; describe('ProfileMenu Notification Logic', () => { beforeEach(() => { jest.clearAllMocks(); + localStorage.clear(); mockApi.getUserStickerBook.mockReset(); mockApi.getUserStickerBook.mockResolvedValue([]); mockApi.markStciekercolledasTrue.mockReset(); @@ -191,4 +203,30 @@ describe('ProfileMenu Notification Logic', () => { expect.any(Object), ); }); + + test('keeps school-mode switch profile on SelectMode selection flow', async () => { + localStorage.setItem(CURRENT_MODE, MODES.TEACHER_SCHOOL); + + render( + + + , + ); + + await waitFor(() => expect(mockApi.getUserStickerBook).toHaveBeenCalled()); + + const switchProfileItem = screen + .getByText(/Switch Profile/i) + .closest('.profile-menu-item'); + fireEvent.click(switchProfileItem!); + + await waitFor(() => { + expect(Util.setCurrentStudent).toHaveBeenCalledWith(null); + }); + expect(mockSetCurrentClass).not.toHaveBeenCalled(); + expect(mockReplace).toHaveBeenCalledWith(PAGES.SELECT_MODE, { + from: '/', + fromSchoolModeSwitchProfile: true, + }); + }); }); diff --git a/src/components/ProfileMenu/ProfileMenu.tsx b/src/components/ProfileMenu/ProfileMenu.tsx index ee1add4a3a..c209da4a59 100644 --- a/src/components/ProfileMenu/ProfileMenu.tsx +++ b/src/components/ProfileMenu/ProfileMenu.tsx @@ -177,6 +177,7 @@ const ProfileMenu = ({ onClose }: ProfileMenuProps) => { setGbUpdated(true); history.replace(PAGES.SELECT_MODE, { from: history.location.pathname, + fromSchoolModeSwitchProfile: true, }); }; diff --git a/src/ops-console/components/DataTableBody.css b/src/ops-console/components/DataTableBody.css index 62fbdb7dff..622ec4f805 100644 --- a/src/ops-console/components/DataTableBody.css +++ b/src/ops-console/components/DataTableBody.css @@ -6,7 +6,8 @@ scrollbar-gutter: stable; margin-bottom: 8px; -webkit-overflow-scrolling: touch; - touch-action: pan-x pan-y; + overscroll-behavior: contain; + touch-action: pan-x pan-y pinch-zoom; } .data-tablebody-head-cell { diff --git a/src/ops-console/components/ProgramListTable.tsx b/src/ops-console/components/ProgramListTable.tsx index 4edeb794fe..34b0f50974 100644 --- a/src/ops-console/components/ProgramListTable.tsx +++ b/src/ops-console/components/ProgramListTable.tsx @@ -40,6 +40,7 @@ type ProgramListTableProps = { isExportDisabled: boolean; isExporting: boolean; onExport: () => void | Promise; + canCreateProgram: boolean; onNewProgram: () => void; columns: Column[]; rows: ProgramListRow[]; @@ -88,7 +89,9 @@ const ProgramListControls: React.FC = (props) => ( isExporting={props.isExporting} onClick={props.onExport} /> - + {props.canCreateProgram ? ( + + ) : null} { ).not.toBeInTheDocument(); }); + it('revokes message editing immediately when the campaign is cancelled', async () => { + apiHandler.getCampaignMessaging.mockResolvedValue( + buildResponse([buildMessagingRow()]), + ); + + const { rerender } = render( + , + ); + + await screen.findByText('Class 1 Digital'); + fireEvent.click(screen.getByLabelText('Edit global send schedule')); + expect( + screen.getByRole('button', { name: 'Save Changes' }), + ).toBeInTheDocument(); + + rerender(); + + await waitFor(() => + expect( + screen.queryByLabelText('Edit global send schedule'), + ).not.toBeInTheDocument(), + ); + await waitFor(() => + expect( + screen.queryByRole('button', { name: 'Save Changes' }), + ).not.toBeInTheDocument(), + ); + expect(apiHandler.updateCampaignMessaging).not.toHaveBeenCalled(); + }); + it('closes the message time picker after selecting a time value', async () => { apiHandler.getCampaignMessaging.mockResolvedValue( buildResponse([buildMessagingRow()]), diff --git a/src/ops-console/components/campaignMessages/CampaignMessages.tsx b/src/ops-console/components/campaignMessages/CampaignMessages.tsx index 5950e03a75..c50949fe5f 100644 --- a/src/ops-console/components/campaignMessages/CampaignMessages.tsx +++ b/src/ops-console/components/campaignMessages/CampaignMessages.tsx @@ -8,24 +8,31 @@ import { useCampaignMessagesController, } from './CampaignMessagesLogic'; import type { CampaignMessagesScheduleType } from './CampaignMessagesLogic'; +import type { CampaignFrequency } from '../../../services/api/ServiceApi'; import './CampaignMessages.css'; interface CampaignMessagesProps { campaignId?: string; campaignStartDate?: string; campaignEndDate?: string; + campaignFrequency?: CampaignFrequency; + isCampaignCancelled?: boolean; } const CampaignMessages: React.FC = ({ campaignId, campaignStartDate, campaignEndDate, + campaignFrequency, + isCampaignCancelled, }) => { const { t } = useTranslation(); const controller = useCampaignMessagesController({ campaignId, campaignStartDate, campaignEndDate, + campaignFrequency, + isCampaignCancelled, translate: (key) => String(t(key)), }); const timePickerWrapperRefs = useRef< diff --git a/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts b/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts index e70cfe7b1a..fdbca3e8ae 100644 --- a/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts +++ b/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts @@ -9,11 +9,16 @@ import { } from 'react'; import { ServiceConfig } from '../../../services/ServiceConfig'; import { + CampaignFrequency, CampaignMessagingQueryParams, CampaignMessagingRow, } from '../../../services/api/ServiceApi'; import { hasCampaignWriteAccess } from '../../../services/api/campaignListingHelpers'; import { Json } from '../../../services/database'; +import { + buildFrequencyTimelineDates, + DEFAULT_FREQUENCY, +} from '../campaignSetup/campaignAssignmentUtils'; import { buildCampaignDurationTimelineDates } from '../campaignSetup/campaignCommunicationUtils'; import { useAppSelector } from '../../../redux/hooks'; import { AuthState } from '../../../redux/slices/auth/authSlice'; @@ -119,6 +124,8 @@ interface UseCampaignMessagesControllerParams { campaignId?: string; campaignStartDate?: string; campaignEndDate?: string; + campaignFrequency?: CampaignFrequency; + isCampaignCancelled?: boolean; translate: (key: string) => string; } @@ -680,12 +687,16 @@ export const useCampaignMessagesController = ({ campaignId, campaignStartDate, campaignEndDate, + campaignFrequency = DEFAULT_FREQUENCY, + isCampaignCancelled = false, translate, }: UseCampaignMessagesControllerParams): CampaignMessagesController => { const { roles } = useAppSelector( (state: RootState) => state.auth as AuthState, ); - const canEdit = hasCampaignWriteAccess(roles || []); + // A cancelled campaign must become read-only even when the user's role + // normally grants campaign write access. + const canEdit = !isCampaignCancelled && hasCampaignWriteAccess(roles || []); const [messagesData, setMessagesData] = useState( () => emptyCampaignMessagesData, ); @@ -712,6 +723,17 @@ export const useCampaignMessagesController = ({ Record >({}); const timelineDates = useMemo( + () => + campaignStartDate && campaignEndDate + ? buildFrequencyTimelineDates( + campaignStartDate, + campaignEndDate, + campaignFrequency, + ) + : [], + [campaignEndDate, campaignFrequency, campaignStartDate], + ); + const campaignRangeDates = useMemo( () => campaignStartDate && campaignEndDate ? buildCampaignDurationTimelineDates(campaignStartDate, campaignEndDate) @@ -751,8 +773,8 @@ export const useCampaignMessagesController = ({ // Fetch the full calendar range so persisted Sunday rows cannot displace // later non-Sunday campaign dates from the paginated response. const pageSize = - timelineDates.length > 0 - ? timelineDates.length + campaignRangeDates.length > 0 + ? campaignRangeDates.length : CAMPAIGN_MESSAGES_PAGE_SIZE; const loadedMessagesData = await loadCampaignMessagesData( campaignId, @@ -774,7 +796,7 @@ export const useCampaignMessagesController = ({ return () => { isMounted = false; }; - }, [campaignId, displayTimelineDates, timelineDates.length]); + }, [campaignId, campaignRangeDates.length, displayTimelineDates]); useEffect( () => () => { @@ -1078,8 +1100,8 @@ export const useCampaignMessagesController = ({ { page: 1, pageSize: - timelineDates.length > 0 - ? timelineDates.length + campaignRangeDates.length > 0 + ? campaignRangeDates.length : CAMPAIGN_MESSAGES_PAGE_SIZE, }, ); diff --git a/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx b/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx index cb5215883e..ad086086a1 100644 --- a/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx +++ b/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx @@ -18,6 +18,7 @@ const CampaignCommunicationTimelineStep: React.FC< CampaignCommunicationTimelineStepProps > = ({ form, + frequency, assignmentDrafts, selectedSchoolIds, communicationState, @@ -31,8 +32,8 @@ const CampaignCommunicationTimelineStep: React.FC< const { t } = useTranslation(); const timeOptions = useMemo(() => buildTimeOptions(), []); const timelineDates = useMemo( - () => buildCommunicationTimelineDates(assignmentDrafts, form), - [assignmentDrafts, form], + () => buildCommunicationTimelineDates(assignmentDrafts, form, frequency), + [assignmentDrafts, form, frequency], ); const { campaignReach, loadingReach } = useCampaignReach(selectedSchoolIds); diff --git a/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx b/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx index 71b23a8b1b..0db46bed49 100644 --- a/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx +++ b/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useRef, useState } from 'react'; import { Autocomplete, Checkbox, + ClickAwayListener, TextField, useMediaQuery, } from '@mui/material'; @@ -40,80 +41,99 @@ export const CampaignMultiSelect = ({ }: CampaignMultiSelectProps) => { const isMobileView = useMediaQuery('(max-width:48rem)'); const shouldPreventKeyboard = preventMobileKeyboard && isMobileView; + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const handleClickAway = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node | null; + if (target && rootRef.current?.contains(target)) { + return; + } + + setOpen(false); + }; return ( - +
+ setOpen(true)} + onClose={() => setOpen(false)} + options={options} + value={value} + loading={loading} + getOptionLabel={getOptionLabel} + isOptionEqualToValue={isOptionEqualToValue} + slotProps={{ + popper: { + placement: 'bottom-start', + modifiers: [ + { + name: 'flip', + enabled: false, + }, + { + name: 'preventOverflow', + options: { + altAxis: true, + padding: 8, + tether: true, + }, + }, + ], }, - { - name: 'preventOverflow', - options: { - altAxis: true, - padding: 8, - tether: true, + paper: { + sx: { + marginTop: 0.5, }, }, - ], - }, - paper: { - sx: { - marginTop: 0.5, - }, - }, - }} - ListboxProps={{ - style: { - maxHeight: '16rem', - overflowY: 'auto', - }, - }} - renderOption={(props, option, { selected }) => { - const { key, ...optionProps } = props as AutocompleteOptionProps; - const label = getOptionLabel ? getOptionLabel(option) : String(option); - return ( -
  • - - {label} -
  • - ); - }} - onChange={(_, nextValue) => onChange(nextValue)} - renderTags={(selected) => - renderSelectedLabel - ? renderSelectedLabel(selected) - : CampaignCountPlaceholder(selected, placeholder) - } - renderInput={(params) => ( - { + const { key, ...optionProps } = props as AutocompleteOptionProps; + const label = getOptionLabel + ? getOptionLabel(option) + : String(option); + return ( +
  • + + {label} +
  • + ); + }} + onChange={(_, nextValue) => onChange(nextValue)} + renderTags={(selected) => + renderSelectedLabel + ? renderSelectedLabel(selected) + : CampaignCountPlaceholder(selected, placeholder) + } + renderInput={(params) => ( + + )} /> - )} - /> +
    + ); }; diff --git a/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx b/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx index 7b47c57c96..fbd15fd72c 100644 --- a/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx +++ b/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx @@ -34,7 +34,9 @@ export const CampaignSetupActions: React.FC = ({ ? !isFormValid || isSubmitting : activeStep === 1 ? !isAssignmentComplete - : false; + : activeStep === 4 + ? isSubmitting + : false; const handleNextClick = () => { if (activeStep === 0) { diff --git a/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx b/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx index 7fb6f3176a..056e3996c9 100644 --- a/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx +++ b/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx @@ -21,6 +21,7 @@ export const TargetAudienceSection: React.FC = ({ savedGroups, selectedSavedGroupId, audienceOptions, + availableGrades, selectedBlocks, selectedSchools, selectedGrades, @@ -29,6 +30,7 @@ export const TargetAudienceSection: React.FC = ({ hasCustomGradeSelection, schoolsForSelectedBlocks, loadingAudience, + loadingGrades, selectedProgramName, summaryBlockCount, summarySchoolCount, @@ -55,6 +57,23 @@ export const TargetAudienceSection: React.FC = ({ () => new Map(programs.map((program) => [program.id, program.name])), [programs], ); + const gradeSelectScopeKey = useMemo(() => { + const schoolKey = + selectedSchools.length === audienceOptions.schools.length + ? 'all-schools' + : selectedSchools + .map((school) => school.id) + .sort() + .join('|'); + const gradeKey = availableGrades.map((grade) => grade.id).join('|'); + + return `${schoolKey}:${gradeKey}`; + }, [audienceOptions.schools.length, availableGrades, selectedSchools]); + const scopedSelectedGrades = useMemo(() => { + const availableGradeIds = new Set(availableGrades.map((grade) => grade.id)); + + return selectedGrades.filter((grade) => availableGradeIds.has(grade.id)); + }, [availableGrades, selectedGrades]); return ( @@ -166,9 +185,10 @@ export const TargetAudienceSection: React.FC = ({ Grade option.name} diff --git a/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts b/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts index 13a00dbf1e..29a3b2ee22 100644 --- a/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts +++ b/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts @@ -1,10 +1,11 @@ import { CampaignAssignmentChapterOption, CampaignAssignmentSubjectOption, + CampaignFrequency, } from '../../../services/api/ServiceApi'; import { CampaignSetupFormState } from './types'; -export type Frequency = 'daily' | 'alternate_days' | 'alternate_week'; +export type Frequency = CampaignFrequency; export type AssignmentRow = { rowId: string; @@ -151,13 +152,18 @@ const getScheduleDates = ( return dates; }; +export const buildFrequencyTimelineDates = ( + startDate: string, + endDate: string, + frequency: Frequency, +): string[] => + getScheduleDates(startDate, endDate, frequency, Number.MAX_SAFE_INTEGER); + export const getRequiredAssignmentCount = ( startDate: string, endDate: string, frequency: Frequency, -) => - getScheduleDates(startDate, endDate, frequency, Number.MAX_SAFE_INTEGER) - .length; +) => buildFrequencyTimelineDates(startDate, endDate, frequency).length; const distributeDatesAcrossAssignments = ( dates: string[], diff --git a/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts b/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts index 44d769926c..3da02b94d5 100644 --- a/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts +++ b/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts @@ -1,4 +1,4 @@ -import { CampaignAssignmentDraft } from './campaignAssignmentUtils'; +import { CampaignAssignmentDraft, Frequency } from './campaignAssignmentUtils'; import { CampaignCommunicationRowState, CampaignCommunicationState, @@ -8,6 +8,7 @@ import { CampaignSetupFormState } from './types'; export type CampaignCommunicationTimelineStepProps = { form: CampaignSetupFormState; + frequency: Frequency; assignmentDrafts: CampaignAssignmentDraft[]; selectedSchoolIds: string[]; communicationState: CampaignCommunicationState; diff --git a/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts b/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts index 62faf438dd..4cbd048100 100644 --- a/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts +++ b/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts @@ -1,6 +1,10 @@ import { t } from 'i18next'; import { CAMPAIGN_OBJECTIVE } from '../../../common/constants'; -import { CampaignAssignmentDraft } from './campaignAssignmentUtils'; +import { + buildFrequencyTimelineDates, + CampaignAssignmentDraft, + Frequency, +} from './campaignAssignmentUtils'; import { CampaignSetupFormState } from './types'; export type CampaignCommunicationRowState = { @@ -51,11 +55,16 @@ export const createEmptyCommunicationRow = export const buildCommunicationTimelineDates = ( assignmentDrafts: CampaignAssignmentDraft[], form?: CampaignSetupFormState, + frequency?: Frequency, ): string[] => { if (form?.objective === CAMPAIGN_OBJECTIVE.HOMEPAGE_LEARNING_PATHWAY) { return buildCampaignDurationTimelineDates(form.startDate, form.endDate); } + if (form && frequency) { + return buildFrequencyTimelineDates(form.startDate, form.endDate, frequency); + } + return Array.from(new Set(assignmentDrafts.map((draft) => draft.startsAt))) .filter(Boolean) .sort(); diff --git a/src/ops-console/components/campaignSetup/types.ts b/src/ops-console/components/campaignSetup/types.ts index 4a25ddc1de..d4336bf545 100644 --- a/src/ops-console/components/campaignSetup/types.ts +++ b/src/ops-console/components/campaignSetup/types.ts @@ -73,6 +73,7 @@ export type TargetAudienceSectionProps = { savedGroups: CampaignSavedAudienceGroup[]; selectedSavedGroupId: string; audienceOptions: CampaignAudienceOptions; + availableGrades: CampaignOption[]; selectedBlocks: string[]; selectedSchools: CampaignSchoolOption[]; selectedGrades: CampaignOption[]; @@ -81,6 +82,7 @@ export type TargetAudienceSectionProps = { hasCustomGradeSelection: boolean; schoolsForSelectedBlocks: CampaignSchoolOption[]; loadingAudience: boolean; + loadingGrades: boolean; selectedProgramName: string; summaryBlockCount: number; summarySchoolCount: number; diff --git a/src/ops-console/components/campaignSetup/useCampaignReach.ts b/src/ops-console/components/campaignSetup/useCampaignReach.ts index 069be7b6c2..ac0eec59cb 100644 --- a/src/ops-console/components/campaignSetup/useCampaignReach.ts +++ b/src/ops-console/components/campaignSetup/useCampaignReach.ts @@ -21,7 +21,7 @@ export const useCampaignReach = (selectedSchoolIds: string[]) => { if ( selectedSchoolIds.length === 0 || !api.getParentWhatsappClassesBySchoolId || - !api.getParentWhatsappParentPhonesByClassId + !api.getCampaignParentsInGroupBySchoolIds ) { setCampaignReach(emptyReach); return; @@ -29,39 +29,21 @@ export const useCampaignReach = (selectedSchoolIds: string[]) => { setLoadingReach(true); try { - const schoolClasses = await Promise.all( - selectedSchoolIds.map((schoolId) => - api.getParentWhatsappClassesBySchoolId!(schoolId), - ), - ); - - const groupedClasses = schoolClasses - .flat() - .filter( - (classRow) => - classRow.group_id && String(classRow.group_id).trim() !== '', - ); + const schoolClasses = + await api.getParentWhatsappClassesBySchoolId(selectedSchoolIds); - const memberLists = await Promise.all( - groupedClasses.map(async (classRow) => { - try { - const phones = await api.getParentWhatsappParentPhonesByClassId!( - classRow.id, - ); - return Array.from(new Set(phones)); - } catch { - return []; - } - }), + const groupedClasses = schoolClasses.filter( + (classRow) => + classRow.group_id && String(classRow.group_id).trim() !== '', ); + const memberCount = + await api.getCampaignParentsInGroupBySchoolIds(selectedSchoolIds); + if (!mounted) return; setCampaignReach({ groupCount: groupedClasses.length, - memberCount: memberLists.reduce( - (total, members) => total + members.length, - 0, - ), + memberCount, }); } catch (error) { logger.error('Failed to load campaign reach:', error); diff --git a/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx b/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx index 73cba41bdc..f4074df418 100644 --- a/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx +++ b/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx @@ -155,12 +155,19 @@ const CampaignsOverview: React.FC = ({ {shouldShowMessages && ( )} {shouldShowReports && ( diff --git a/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx b/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx index a3f58ff308..56b3a79cb9 100644 --- a/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx +++ b/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx @@ -112,7 +112,13 @@ const CampaignsOverviewInfoTooltip: React.FC< type="button" aria-label={`${label} info`} aria-expanded={resolvedIsOpen} - onClick={handleToggle} + onClick={(event) => { + event.stopPropagation(); + handleToggle(); + }} + onPointerDown={(event) => { + event.stopPropagation(); + }} onFocus={keepTooltipWithinParent} onMouseEnter={keepTooltipWithinParent} > diff --git a/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts b/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts index 560712e06c..bd8e3e59f5 100644 --- a/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts +++ b/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts @@ -1,3 +1,5 @@ +import type { CampaignFrequency } from '../../../services/api/ServiceApi'; + export type CampaignsOverviewDisplayValue = | string | number @@ -72,6 +74,7 @@ export interface CampaignsOverviewApiCampaign { objective?: string | null; start_date?: string | null; end_date?: string | null; + frequency?: CampaignFrequency | null; rewards?: string | null; updated_at?: string | null; campaign_status?: CampaignStatus | null; diff --git a/src/ops-console/hooks/useCampaignAudienceSelection.ts b/src/ops-console/hooks/useCampaignAudienceSelection.ts index f23c48d9aa..8f1139b209 100644 --- a/src/ops-console/hooks/useCampaignAudienceSelection.ts +++ b/src/ops-console/hooks/useCampaignAudienceSelection.ts @@ -72,6 +72,8 @@ export const useCampaignAudienceSelection = ({ const [hasCustomSchoolSelection, setHasCustomSchoolSelection] = useState(false); const [hasCustomGradeSelection, setHasCustomGradeSelection] = useState(false); + const [availableGrades, setAvailableGrades] = useState([]); + const [loadingGrades, setLoadingGrades] = useState(false); const [loadingAudience, setLoadingAudience] = useState(false); const resetAudienceSelection = () => { @@ -82,9 +84,11 @@ export const useCampaignAudienceSelection = ({ setHasCustomBlockSelection(false); setHasCustomSchoolSelection(false); setHasCustomGradeSelection(false); + setAvailableGrades([]); setAudienceOptions(emptyAudienceOptions); setAudienceSummary(emptyAudienceSummary); setLoadingAudience(false); + setLoadingGrades(false); setLoadingAudienceSummary(false); setSaveGroup(false); setMessage(null); @@ -105,6 +109,8 @@ export const useCampaignAudienceSelection = ({ setSelectedBlocks([]); setSelectedSchools([]); setSelectedGrades([]); + setAvailableGrades([]); + setLoadingGrades(false); setLoadingAudience(false); return; } @@ -144,19 +150,10 @@ export const useCampaignAudienceSelection = ({ : audienceOptions.blocks, ); } - - if (!hasCustomGradeSelection) { - setSelectedGrades((current) => - areOptionIdArraysEqual(current, audienceOptions.grades) - ? current - : audienceOptions.grades, - ); - } }, [ audienceOptions, form.programId, hasCustomBlockSelection, - hasCustomGradeSelection, selectedSavedGroupId, ]); @@ -195,18 +192,91 @@ export const useCampaignAudienceSelection = ({ () => selectedSchools.map((school) => school.id), [selectedSchools], ); - const selectedGradeIds = useMemo( - () => selectedGrades.map((grade) => grade.id), - [selectedGrades], - ); const isAllSchools = selectedSchoolIds.length === 0 || (allSchoolIds.length > 0 && selectedSchoolIds.length === allSchoolIds.length); + + useEffect(() => { + let isActive = true; + + const fetchAvailableGrades = async () => { + if (!form.programId) { + setAvailableGrades([]); + setLoadingGrades(false); + return; + } + + if (isAllSchools) { + setAvailableGrades((current) => + areOptionIdArraysEqual(current, audienceOptions.grades) + ? current + : audienceOptions.grades, + ); + setLoadingGrades(false); + return; + } + + if (selectedSchoolIds.length === 0) { + setAvailableGrades([]); + setLoadingGrades(false); + return; + } + + setLoadingGrades(true); + try { + const grades = await api.getCampaignGradesForSchools(selectedSchoolIds); + if (!isActive) return; + setAvailableGrades(grades); + } catch (error) { + if (!isActive) return; + logger.error('Failed to load campaign grades for schools:', error); + setAvailableGrades([]); + setMessage({ + type: 'error', + text: 'Unable to load grades for the selected schools.', + }); + } finally { + if (isActive) setLoadingGrades(false); + } + }; + + fetchAvailableGrades(); + + return () => { + isActive = false; + }; + }, [ + api, + audienceOptions.grades, + form.programId, + isAllSchools, + selectedSchoolIds, + setMessage, + ]); + + useEffect(() => { + if (loadingGrades) return; + + setSelectedGrades((current) => { + const availableGradeIds = new Set( + availableGrades.map((grade) => grade.id), + ); + const nextGrades = hasCustomGradeSelection + ? current.filter((grade) => availableGradeIds.has(grade.id)) + : availableGrades; + + return areOptionIdArraysEqual(current, nextGrades) ? current : nextGrades; + }); + }, [availableGrades, hasCustomGradeSelection, loadingGrades]); + + const selectedGradeIds = useMemo( + () => selectedGrades.map((grade) => grade.id), + [selectedGrades], + ); const isAllGrades = - selectedGradeIds.length === 0 || - (audienceOptions.grades.length > 0 && - selectedGradeIds.length === audienceOptions.grades.length); + availableGrades.length > 0 && + selectedGradeIds.length === availableGrades.length; const selectedProgramName = programs.find((program) => program.id === form.programId)?.name || '-'; @@ -216,10 +286,8 @@ export const useCampaignAudienceSelection = ({ ); const summaryGradeIds = useMemo( () => - isAllGrades - ? audienceOptions.grades.map((grade) => grade.id) - : selectedGradeIds, - [audienceOptions.grades, isAllGrades, selectedGradeIds], + isAllGrades ? availableGrades.map((grade) => grade.id) : selectedGradeIds, + [availableGrades, isAllGrades, selectedGradeIds], ); const summaryBlockCount = isAllSchools ? audienceOptions.blocks.length @@ -344,6 +412,7 @@ export const useCampaignAudienceSelection = ({ return { audienceOptions, audienceSummary, + availableGrades, handleBlocksChange, handleGradesChange, handleProgramChange, @@ -356,6 +425,7 @@ export const useCampaignAudienceSelection = ({ isAllSchools, loadingAudience, loadingAudienceSummary, + loadingGrades, resetAudienceSelection, schoolsForSelectedBlocks, selectedBlocks, diff --git a/src/ops-console/hooks/useCampaignSetupForm.ts b/src/ops-console/hooks/useCampaignSetupForm.ts index 69ccca05f6..4b129c1995 100644 --- a/src/ops-console/hooks/useCampaignSetupForm.ts +++ b/src/ops-console/hooks/useCampaignSetupForm.ts @@ -94,6 +94,13 @@ export const useCampaignSetupForm = () => { const [submitAttempted, setSubmitAttempted] = useState(false); const [rewardSubmitAttempted, setRewardSubmitAttempted] = useState(false); const [message, setMessage] = useState(null); + const assignmentFrequency = useMemo( + () => + assignmentConfigs[activeAssignmentGradeId]?.frequency ?? + Object.values(assignmentConfigs)[0]?.frequency ?? + DEFAULT_FREQUENCY, + [activeAssignmentGradeId, assignmentConfigs], + ); const audience = useCampaignAudienceSelection({ api, @@ -431,6 +438,7 @@ export const useCampaignSetupForm = () => { assignmentOptions, areRewardsValid, assignmentConfigs, + assignmentFrequency, assignmentDrafts, campaignRewards, createdCampaignId, diff --git a/src/ops-console/pages/CampaignAssignmentTab.css b/src/ops-console/pages/CampaignAssignmentTab.css index ed814ac880..36fe15b291 100644 --- a/src/ops-console/pages/CampaignAssignmentTab.css +++ b/src/ops-console/pages/CampaignAssignmentTab.css @@ -93,6 +93,16 @@ padding-bottom: 0.375rem; } +.campaign-assignment-menu + .MuiMenuItem-root.campaign-assignment-menu-item-selected { + background-color: rgba(25, 118, 210, 0.08); +} + +.campaign-assignment-menu + .MuiMenuItem-root.campaign-assignment-menu-item-selected:hover { + background-color: rgba(25, 118, 210, 0.12); +} + .campaign-assignment-filterField .campaign-assignment-filterLabel { white-space: nowrap; } diff --git a/src/ops-console/pages/CampaignAssignmentTab.test.tsx b/src/ops-console/pages/CampaignAssignmentTab.test.tsx index 86bdbcbaf1..531df69bc2 100644 --- a/src/ops-console/pages/CampaignAssignmentTab.test.tsx +++ b/src/ops-console/pages/CampaignAssignmentTab.test.tsx @@ -62,7 +62,6 @@ jest.mock('../components/DataTablePagination', () => ({ describe('CampaignAssignmentTab', () => { const api = { getAllGrades: jest.fn(), - getCampaignSubjectsByCampaignId: jest.fn(), getCampaignAssignments: jest.fn(), }; @@ -72,8 +71,8 @@ describe('CampaignAssignmentTab', () => { ]; const defaultSubjects = [ - { id: '11', name: 'Math' }, - { id: '12', name: 'Science' }, + { id: '11', name: 'Math', gradeIds: ['1'] }, + { id: '12', name: 'Science', gradeIds: ['2'] }, ]; const defaultAssignments = [ @@ -85,41 +84,42 @@ describe('CampaignAssignmentTab', () => { }, ]; + const defaultUniqueSubjects = defaultSubjects; + const renderTab = (campaignId?: string) => render(); const primeApi = ({ grades = defaultGrades, - subjects = defaultSubjects, assignments = defaultAssignments, + uniqueSubjects = defaultUniqueSubjects, total = 1, gradeError = null, - subjectError = null, assignmentError = null, }: { grades?: Array<{ id: string; name: string }>; - subjects?: Array<{ id: string; name: string }>; assignments?: Array<{ assignmentDate: string; gradeName: string; subjectName: string; lessonName: string; }>; + uniqueSubjects?: Array<{ + id: string; + name: string; + gradeIds: string[]; + }>; total?: number; gradeError?: Error | null; - subjectError?: Error | null; assignmentError?: Error | null; } = {}) => { api.getAllGrades.mockImplementation(() => gradeError ? Promise.reject(gradeError) : Promise.resolve(grades), ); - api.getCampaignSubjectsByCampaignId.mockImplementation(() => - subjectError ? Promise.reject(subjectError) : Promise.resolve(subjects), - ); api.getCampaignAssignments.mockImplementation(() => assignmentError ? Promise.reject(assignmentError) - : Promise.resolve({ assignments, total }), + : Promise.resolve({ assignments, uniqueSubjects, total }), ); }; @@ -135,11 +135,6 @@ describe('CampaignAssignmentTab', () => { renderTab('campaign-1'); - await waitFor(() => - expect(api.getCampaignSubjectsByCampaignId).toHaveBeenCalledWith( - 'campaign-1', - ), - ); await waitFor(() => expect(api.getCampaignAssignments).toHaveBeenCalledWith('campaign-1', { page: 1, @@ -148,7 +143,7 @@ describe('CampaignAssignmentTab', () => { ); expect(api.getAllGrades).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('data-table')).toBeInTheDocument(); + expect(await screen.findByTestId('data-table')).toBeInTheDocument(); expect(screen.getByText('Lesson Alpha')).toBeInTheDocument(); }); @@ -173,7 +168,6 @@ describe('CampaignAssignmentTab', () => { expect(await screen.findByText('No Assignments Found')).toBeInTheDocument(); expect(api.getAllGrades).not.toHaveBeenCalled(); - expect(api.getCampaignSubjectsByCampaignId).not.toHaveBeenCalled(); expect(api.getCampaignAssignments).not.toHaveBeenCalled(); }); @@ -270,16 +264,29 @@ describe('CampaignAssignmentTab', () => { it('shows the loading spinner while requests are pending', () => { api.getAllGrades.mockImplementation(() => new Promise(() => undefined)); - api.getCampaignSubjectsByCampaignId.mockImplementation( + api.getCampaignAssignments.mockImplementation( () => new Promise(() => undefined), ); + + renderTab('campaign-1'); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('keeps filters disabled until campaign grade metadata is loaded', async () => { + api.getAllGrades.mockResolvedValue(defaultGrades); api.getCampaignAssignments.mockImplementation( () => new Promise(() => undefined), ); renderTab('campaign-1'); - expect(screen.getByRole('progressbar')).toBeInTheDocument(); + await waitFor(() => expect(api.getAllGrades).toHaveBeenCalled()); + screen + .getAllByRole('combobox') + .forEach((combobox) => + expect(combobox).toHaveAttribute('aria-disabled', 'true'), + ); }); it('renders the default grade filter value', async () => { @@ -290,6 +297,21 @@ describe('CampaignAssignmentTab', () => { expect(await screen.findByText('All grades')).toBeInTheDocument(); }); + it('shows only campaign grades from getAllGrades by default', async () => { + primeApi({ + grades: [...defaultGrades, { id: '3', name: 'Grade 3' }], + }); + + renderTab('campaign-1'); + + const [gradeSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(gradeSelect); + + expect(await screen.findByText('Grade 1')).toBeInTheDocument(); + expect(screen.getByText('Grade 2')).toBeInTheDocument(); + expect(screen.queryByText('Grade 3')).not.toBeInTheDocument(); + }); + it('renders the default subject filter value', async () => { primeApi(); @@ -298,6 +320,90 @@ describe('CampaignAssignmentTab', () => { expect(await screen.findByText('All subjects')).toBeInTheDocument(); }); + it('shows every campaign subject as selected when the subject menu opens', async () => { + primeApi(); + + renderTab('campaign-1'); + + const [, subjectSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(subjectSelect); + + expect(await screen.findByText('Math')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')).toHaveLength(2); + screen + .getAllByRole('checkbox') + .forEach((checkbox) => expect(checkbox).toBeChecked()); + + fireEvent.click(screen.getAllByRole('checkbox')[0]); + + await waitFor(() => + expect(screen.getAllByRole('checkbox')[0]).not.toBeChecked(), + ); + expect(screen.getAllByRole('checkbox')[1]).toBeChecked(); + expect(api.getCampaignAssignments).toHaveBeenLastCalledWith('campaign-1', { + page: 1, + pageSize: 20, + subjectIds: ['12'], + }); + }); + + it('keeps every campaign subject visible when grades are selected', async () => { + primeApi(); + + renderTab('campaign-1'); + + const [gradeSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(gradeSelect); + fireEvent.click(screen.getAllByRole('checkbox')[1]); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' }); + + await waitFor(() => + expect(api.getCampaignAssignments).toHaveBeenLastCalledWith( + 'campaign-1', + { + page: 1, + pageSize: 20, + gradeIds: ['1'], + }, + ), + ); + + const [, subjectSelect] = screen.getAllByRole('combobox'); + fireEvent.mouseDown(subjectSelect); + + expect(await screen.findByText('Math')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + }); + + it('keeps every campaign grade visible when subjects are selected', async () => { + primeApi(); + + renderTab('campaign-1'); + + const [, subjectSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(subjectSelect); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' }); + + await waitFor(() => + expect(api.getCampaignAssignments).toHaveBeenLastCalledWith( + 'campaign-1', + { + page: 1, + pageSize: 20, + subjectIds: ['12'], + }, + ), + ); + + const [gradeSelect] = screen.getAllByRole('combobox'); + fireEvent.mouseDown(gradeSelect); + + expect(await screen.findByText('Grade 1')).toBeInTheDocument(); + expect(screen.getByText('Grade 2')).toBeInTheDocument(); + }); + it('renders both filter comboboxes', async () => { primeApi(); diff --git a/src/ops-console/pages/CampaignAssignmentTab.tsx b/src/ops-console/pages/CampaignAssignmentTab.tsx index e9c42274d1..8cb5b67b12 100644 --- a/src/ops-console/pages/CampaignAssignmentTab.tsx +++ b/src/ops-console/pages/CampaignAssignmentTab.tsx @@ -19,6 +19,7 @@ import DataTableBody, { type Column } from '../components/DataTableBody'; import DataTablePagination from '../components/DataTablePagination'; import type { CampaignAssignmentSummaryRow, + CampaignAssignmentUniqueSubject, CampaignOption, } from '../../services/api/ServiceApi'; @@ -49,7 +50,9 @@ const CampaignAssignmentTab: React.FC = ({ const isMediumScreen = viewportWidth > 600 && viewportWidth <= 900; const [grades, setGrades] = useState([]); - const [subjects, setSubjects] = useState([]); + const [subjects, setSubjects] = useState( + [], + ); const [assignments, setAssignments] = useState< CampaignAssignmentSummaryRow[] >([]); @@ -75,10 +78,7 @@ const CampaignAssignmentTab: React.FC = ({ setIsLoadingFilters(true); try { - const [nextGrades, nextSubjects] = await Promise.all([ - api.getAllGrades(), - api.getCampaignSubjectsByCampaignId(campaignId), - ]); + const nextGrades = await api.getAllGrades(); if (cancelled) return; @@ -88,12 +88,6 @@ const CampaignAssignmentTab: React.FC = ({ name: String(grade.name), })), ); - setSubjects( - nextSubjects.map((subject) => ({ - id: String(subject.id), - name: String(subject.name), - })), - ); } catch (err) { logger.error('Failed to load campaign assignment filters:', err); } finally { @@ -108,6 +102,13 @@ const CampaignAssignmentTab: React.FC = ({ }; }, [api, campaignId]); + useEffect(() => { + setSelectedGrades([]); + setSelectedSubjects([]); + setSubjects([]); + setPage(1); + }, [campaignId]); + useEffect(() => { let cancelled = false; @@ -131,6 +132,9 @@ const CampaignAssignmentTab: React.FC = ({ if (cancelled) return; setAssignments(response.assignments ?? []); + if (selectedGrades.length === 0 && selectedSubjects.length === 0) { + setSubjects(response.uniqueSubjects ?? []); + } setTotal(response.total ?? 0); } catch (err) { logger.error('Failed to load campaign assignments:', err); @@ -169,6 +173,49 @@ const CampaignAssignmentTab: React.FC = ({ [subjects], ); + const availableSubjects = subjects; + + const availableGrades = useMemo(() => { + const relevantGradeIds = new Set( + subjects.flatMap((subject) => subject.gradeIds), + ); + + return grades.filter((grade) => relevantGradeIds.has(grade.id)); + }, [grades, subjects]); + + useEffect(() => { + const availableGradeIds = new Set(availableGrades.map((grade) => grade.id)); + setSelectedGrades((current) => { + if (current.length === 0) return current; + const next = current.filter((id) => availableGradeIds.has(id)); + return next.length === current.length ? current : next; + }); + }, [availableGrades]); + + useEffect(() => { + const availableSubjectIds = new Set( + availableSubjects.map((subject) => subject.id), + ); + setSelectedSubjects((current) => { + if (current.length === 0) return current; + const next = current.filter((id) => availableSubjectIds.has(id)); + return next.length === current.length ? current : next; + }); + }, [availableSubjects]); + + const gradeOptionIds = useMemo( + () => availableGrades.map((grade) => grade.id), + [availableGrades], + ); + const subjectOptionIds = useMemo( + () => availableSubjects.map((subject) => subject.id), + [availableSubjects], + ); + const gradeSelectValues = + selectedGrades.length === 0 ? gradeOptionIds : selectedGrades; + const subjectSelectValues = + selectedSubjects.length === 0 ? subjectOptionIds : selectedSubjects; + const dateColumnWidth = isSmallScreen ? 180 : isMediumScreen ? 240 : 280; const gradeColumnWidth = isSmallScreen ? 100 : 140; const subjectColumnWidth = isSmallScreen ? 120 : 180; @@ -225,10 +272,16 @@ const CampaignAssignmentTab: React.FC = ({ const selectedSubjectLabel = (id: string) => subjectNameById.get(id) ?? id; const handleMultiSelectChange = - (setter: React.Dispatch>) => + ( + options: CampaignOption[], + setter: React.Dispatch>, + ) => (event: SelectChangeEvent) => { const value = event.target.value; - setter(typeof value === 'string' ? value.split(',') : value); + const nextValues = typeof value === 'string' ? value.split(',') : value; + const optionIds = options.map((option) => option.id); + + setter(nextValues.length === optionIds.length ? [] : nextValues); setPage(1); }; @@ -247,17 +300,28 @@ const CampaignAssignmentTab: React.FC = ({ } renderValue={(selected) => { const values = selected as string[]; - if (values.length === 0) return t('All subjects'); + if ( + values.length === 0 || + values.length === subjectOptionIds.length + ) { + return t('All subjects'); + } return values.map(selectedSubjectLabel).join(', '); }} className="campaign-assignment-select" @@ -303,9 +392,23 @@ const CampaignAssignmentTab: React.FC = ({ PaperProps: { className: 'campaign-assignment-menu' }, }} > - {subjects.map((subject) => ( - - + {availableSubjects.map((subject) => ( + + ))} diff --git a/src/ops-console/pages/CampaignListingPage.css b/src/ops-console/pages/CampaignListingPage.css index 5e14bf1bef..0a72ed93cf 100644 --- a/src/ops-console/pages/CampaignListingPage.css +++ b/src/ops-console/pages/CampaignListingPage.css @@ -170,7 +170,6 @@ background-color: #f8fafc !important; border-bottom: 1px solid #e3e8f1; color: #121619; - font-size: 11px; overflow: visible; padding: 14px 16px; z-index: 3; @@ -478,19 +477,24 @@ } .campaign-listing-header { + position: relative; + display: flex; align-items: center; - justify-content: space-between; - padding: 14px 16px 12px; - min-height: 52px; + justify-content: flex-end; + font-size: 24px; + padding: 5%; } .campaign-listing-header-title { - flex: 1 1 auto; - min-width: 0; - line-height: 1.2; - font-size: 22px !important; - padding-right: 12px; - text-align: left; + position: absolute; + left: 0; + right: 0; + margin: auto; + width: max-content; + text-align: center; + pointer-events: none; + font-size: 24px; + font-weight: 600; } .campaign-listing-notification-button.MuiIconButton-root { diff --git a/src/ops-console/pages/CampaignListingPage.tsx b/src/ops-console/pages/CampaignListingPage.tsx index d0cf23ba67..58164c6f80 100644 --- a/src/ops-console/pages/CampaignListingPage.tsx +++ b/src/ops-console/pages/CampaignListingPage.tsx @@ -109,9 +109,7 @@ const CampaignListingPage: React.FC = () => { return ( - - {t('Campaigns')} - + {t('Campaigns')} diff --git a/src/ops-console/pages/CampaignSetupPage.test.tsx b/src/ops-console/pages/CampaignSetupPage.test.tsx index 9b36da259b..89219ae002 100644 --- a/src/ops-console/pages/CampaignSetupPage.test.tsx +++ b/src/ops-console/pages/CampaignSetupPage.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + act, fireEvent, render, screen, @@ -43,6 +44,7 @@ jest.mock('react-router-dom', () => ({ const mockApiHandler = { getCampaignSetupOptions: jest.fn(), getCampaignAudienceOptions: jest.fn(), + getCampaignGradesForSchools: jest.fn(), getCampaignAudienceSummary: jest.fn(), createCampaignAudienceGroup: jest.fn(), createCampaignSetup: jest.fn(), @@ -120,6 +122,9 @@ const setupApiMocks = () => { schools: [{ id: 'school-1', name: 'School One', block: 'Block A' }], grades: [{ id: 'grade-1', name: 'Grade 1' }], }); + mockApiHandler.getCampaignGradesForSchools.mockResolvedValue([ + { id: 'grade-1', name: 'Grade 1' }, + ]); mockApiHandler.getCampaignAudienceSummary.mockResolvedValue({ totalStudents: 10, grades: [{ gradeId: 'grade-1', gradeName: 'Grade 1', studentCount: 10 }], @@ -334,6 +339,195 @@ describe('CampaignSetupPage', () => { expect(within(schoolOption).getByRole('checkbox')).toBeChecked(); }); + it('refreshes grade options after narrowing the selected schools', async () => { + mockApiHandler.getCampaignAudienceOptions.mockResolvedValueOnce({ + blocks: ['Block A'], + schools: [ + { id: 'school-1', name: 'School One', block: 'Block A' }, + { id: 'school-2', name: 'School Two', block: 'Block A' }, + ], + grades: [ + { id: 'grade-1', name: 'Grade 1' }, + { id: 'grade-2', name: 'Grade 2' }, + ], + }); + mockApiHandler.getCampaignGradesForSchools.mockImplementation( + (schoolIds: string[]) => + Promise.resolve( + schoolIds.includes('school-2') + ? [{ id: 'grade-1', name: 'Grade 1' }] + : [ + { id: 'grade-1', name: 'Grade 1' }, + { id: 'grade-2', name: 'Grade 2' }, + ], + ), + ); + + render(); + + await screen.findByRole('heading', { name: 'New Campaign' }); + await openSelectAndChoose('Select Program', 'Early Learning'); + + const schoolField = screen + .getByText('School') + .closest('.campaign-setup-field') as HTMLElement | null; + const schoolSelect = schoolField + ? within(schoolField).getByRole('combobox') + : null; + + fireEvent.mouseDown(schoolSelect as HTMLElement); + fireEvent.click(await screen.findByRole('option', { name: 'School One' })); + fireEvent.keyDown(screen.getByRole('listbox'), { + key: 'Escape', + code: 'Escape', + }); + + await waitFor(() => + expect(mockApiHandler.getCampaignGradesForSchools).toHaveBeenCalledWith([ + 'school-2', + ]), + ); + + const gradeField = screen + .getByText('Grade') + .closest('.campaign-setup-field') as HTMLElement | null; + const gradeSelect = gradeField + ? within(gradeField).getByRole('combobox') + : null; + + fireEvent.mouseDown(gradeSelect as HTMLElement); + + const gradeOneOption = await screen.findByRole('option', { + name: 'Grade 1', + }); + expect(within(gradeOneOption).getByRole('checkbox')).toBeChecked(); + expect( + screen.queryByRole('option', { name: 'Grade 2' }), + ).not.toBeInTheDocument(); + }); + + it('preserves selected grades that remain available after a school refresh', async () => { + let resolveGrades: + | ((grades: Array<{ id: string; name: string }>) => void) + | undefined; + + mockApiHandler.getCampaignAudienceOptions.mockResolvedValueOnce({ + blocks: ['Block A'], + schools: [ + { id: 'school-1', name: 'School One', block: 'Block A' }, + { id: 'school-2', name: 'School Two', block: 'Block A' }, + ], + grades: [ + { id: 'grade-1', name: 'Grade 1' }, + { id: 'grade-2', name: 'Grade 2' }, + ], + }); + mockApiHandler.getCampaignGradesForSchools.mockImplementation( + () => + new Promise((resolve) => { + resolveGrades = resolve; + }), + ); + + render(); + + await screen.findByRole('heading', { name: 'New Campaign' }); + await openSelectAndChoose('Select Program', 'Early Learning'); + expect(await screen.findByText(/Grade 1/)).toBeInTheDocument(); + + const gradeField = screen + .getByText('Grade') + .closest('.campaign-setup-field') as HTMLElement | null; + const gradeSelect = gradeField + ? within(gradeField).getByRole('combobox') + : null; + + fireEvent.mouseDown(gradeSelect as HTMLElement); + fireEvent.click(await screen.findByRole('option', { name: 'Grade 2' })); + fireEvent.keyDown(screen.getByRole('listbox'), { + key: 'Escape', + code: 'Escape', + }); + + const schoolField = screen + .getByText('School') + .closest('.campaign-setup-field') as HTMLElement | null; + const schoolSelect = schoolField + ? within(schoolField).getByRole('combobox') + : null; + + fireEvent.mouseDown(schoolSelect as HTMLElement); + fireEvent.click(await screen.findByRole('option', { name: 'School One' })); + + await waitFor(() => + expect(mockApiHandler.getCampaignGradesForSchools).toHaveBeenCalledWith([ + 'school-2', + ]), + ); + + await act(async () => { + resolveGrades?.([{ id: 'grade-1', name: 'Grade 1' }]); + }); + + const refreshedGradeField = screen + .getByText('Grade') + .closest('.campaign-setup-field') as HTMLElement | null; + const refreshedGradeSelect = refreshedGradeField + ? within(refreshedGradeField).getByRole('combobox') + : null; + + fireEvent.mouseDown(refreshedGradeSelect as HTMLElement); + + const gradeOneOption = await screen.findByRole('option', { + name: 'Grade 1', + }); + expect(within(gradeOneOption).getByRole('checkbox')).toBeChecked(); + }); + + it('closes the grade dropdown after all grades are manually unchecked', async () => { + mockApiHandler.getCampaignAudienceOptions.mockResolvedValueOnce({ + blocks: ['Block A'], + schools: [{ id: 'school-1', name: 'School One', block: 'Block A' }], + grades: [ + { id: 'grade-1', name: 'Grade 1' }, + { id: 'grade-2', name: 'Grade 2' }, + ], + }); + mockApiHandler.getCampaignAudienceSummary.mockResolvedValue({ + totalStudents: 20, + grades: [ + { gradeId: 'grade-1', gradeName: 'Grade 1', studentCount: 10 }, + { gradeId: 'grade-2', gradeName: 'Grade 2', studentCount: 10 }, + ], + }); + + render(); + + await screen.findByRole('heading', { name: 'New Campaign' }); + await openSelectAndChoose('Select Program', 'Early Learning'); + expect(await screen.findByText(/Grade 1/)).toBeInTheDocument(); + + const gradeField = screen + .getByText('Grade') + .closest('.campaign-setup-field') as HTMLElement | null; + const gradeSelect = gradeField + ? within(gradeField).getByRole('combobox') + : null; + + fireEvent.mouseDown(gradeSelect as HTMLElement); + fireEvent.click(await screen.findByRole('option', { name: 'Grade 1' })); + fireEvent.click(await screen.findByRole('option', { name: 'Grade 2' })); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body); + + await waitFor(() => + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(), + ); + expect(screen.queryByText(/Grade 1 -/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Grade 2 -/)).not.toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); + it('switches dynamic objective fields when homepage pathway campaign is selected', async () => { render(); diff --git a/src/ops-console/pages/CampaignSetupPage.tsx b/src/ops-console/pages/CampaignSetupPage.tsx index 4cf4a6cad3..00d8715a5e 100644 --- a/src/ops-console/pages/CampaignSetupPage.tsx +++ b/src/ops-console/pages/CampaignSetupPage.tsx @@ -68,8 +68,13 @@ const CampaignSetupPage: React.FC = () => { buildCommunicationTimelineDates( campaignSetup.assignmentDrafts, campaignSetup.form, + campaignSetup.assignmentFrequency, ), - [campaignSetup.assignmentDrafts, campaignSetup.form], + [ + campaignSetup.assignmentDrafts, + campaignSetup.assignmentFrequency, + campaignSetup.form, + ], ); const communicationValidation = useMemo( @@ -140,7 +145,7 @@ const CampaignSetupPage: React.FC = () => { ? campaignSetup.audienceOptions.schools : campaignSetup.selectedSchools, selectedGrades: campaignSetup.isAllGrades - ? campaignSetup.audienceOptions.grades + ? campaignSetup.availableGrades : campaignSetup.selectedGrades, audienceSummary: campaignSetup.audienceSummary, assignmentDrafts: campaignSetup.assignmentDrafts, @@ -156,7 +161,7 @@ const CampaignSetupPage: React.FC = () => { campaignReach, campaignSetup.assignmentConfigs, campaignSetup.assignmentDrafts, - campaignSetup.audienceOptions.grades, + campaignSetup.availableGrades, campaignSetup.audienceOptions.schools, campaignSetup.audienceSummary, campaignSetup.campaignRewards, @@ -292,6 +297,7 @@ const CampaignSetupPage: React.FC = () => { const campaign = { programId: campaignSetup.form.programId, campaignName: campaignSetup.form.campaignName.trim(), + frequency: campaignSetup.assignmentFrequency, objective: campaignSetup.form.objective as CampaignObjective, targetType: campaignSetup.form.objective === CAMPAIGN_OBJECTIVE.HOMEWORK @@ -461,6 +467,7 @@ const CampaignSetupPage: React.FC = () => { savedGroups={campaignSetup.savedGroups} selectedSavedGroupId={campaignSetup.selectedSavedGroupId} audienceOptions={campaignSetup.audienceOptions} + availableGrades={campaignSetup.availableGrades} selectedBlocks={campaignSetup.selectedBlocks} selectedSchools={campaignSetup.selectedSchools} selectedGrades={campaignSetup.selectedGrades} @@ -473,6 +480,7 @@ const CampaignSetupPage: React.FC = () => { campaignSetup.schoolsForSelectedBlocks } loadingAudience={campaignSetup.loadingAudience} + loadingGrades={campaignSetup.loadingGrades} selectedProgramName={campaignSetup.selectedProgramName} summaryBlockCount={campaignSetup.summaryBlockCount} summarySchoolCount={campaignSetup.summarySchoolCount} @@ -523,6 +531,7 @@ const CampaignSetupPage: React.FC = () => { ) : campaignSetup.activeStep === 3 ? ( => { - return await api.getParentWhatsappClassesBySchoolId(schoolId); + return await api.getParentWhatsappClassesBySchoolId([schoolId]); }; // Fetches WhatsApp group details via parent WhatsApp dedicated RPC path. diff --git a/src/ops-console/pages/ProgramPage.test.tsx b/src/ops-console/pages/ProgramPage.test.tsx index 76530b8773..1aac43decc 100644 --- a/src/ops-console/pages/ProgramPage.test.tsx +++ b/src/ops-console/pages/ProgramPage.test.tsx @@ -34,6 +34,7 @@ type MockProgramListTableProps = { isExportDisabled: boolean; isExporting: boolean; onExport: () => void | Promise; + canCreateProgram: boolean; onNewProgram: () => void; columns: Column[]; rows: ProgramListRow[]; @@ -95,9 +96,11 @@ jest.mock('../components/ProgramListTable', () => { - + {props.canCreateProgram ? ( + + ) : null} @@ -263,3 +266,15 @@ it('does not render Program page for non-admin roles', () => { expect(screen.queryByText('Programs')).not.toBeInTheDocument(); expect(screen.queryByTestId('program-list-table')).not.toBeInTheDocument(); }); + +it('hides program creation for program managers', () => { + mockRoles = [RoleType.PROGRAM_MANAGER]; + + render(); + + expect(screen.getByText('Programs')).toBeInTheDocument(); + expect(screen.getByTestId('program-list-table')).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'new program' }), + ).not.toBeInTheDocument(); +}); diff --git a/src/ops-console/pages/ProgramPage.tsx b/src/ops-console/pages/ProgramPage.tsx index b91a860338..4bf95806e1 100644 --- a/src/ops-console/pages/ProgramPage.tsx +++ b/src/ops-console/pages/ProgramPage.tsx @@ -11,7 +11,13 @@ import type { AuthState } from '../../redux/slices/auth/authSlice'; import './ProgramPage.css'; import './SchoolList.css'; -const ProgramPageContent: React.FC = () => { +type ProgramPageContentProps = { + canCreateProgram: boolean; +}; + +const ProgramPageContent: React.FC = ({ + canCreateProgram, +}) => { const logic = useProgramPageLogic(); return ( @@ -66,6 +72,7 @@ const ProgramPageContent: React.FC = () => { isExportDisabled={logic.isExportDisabled} isExporting={logic.isExporting} onExport={logic.handleExportPrograms} + canCreateProgram={canCreateProgram} onNewProgram={() => logic.history.push(logic.newProgramPath)} columns={logic.columns} rows={logic.rows} @@ -101,13 +108,18 @@ const ProgramsPage: React.FC = () => { RoleType.PROGRAM_MANAGER, ].includes(role as RoleType), ); + const canCreateProgram = userRoles.some((role) => + [RoleType.SUPER_ADMIN, RoleType.OPERATIONAL_DIRECTOR].includes( + role as RoleType, + ), + ); // Program listing is restricted to approved roles so blocked users do not see or fetch page data. if (!canViewProgramPage) { return null; } - return ; + return ; }; export default ProgramsPage; diff --git a/src/ops-console/pages/SchoolList.helpers.tsx b/src/ops-console/pages/SchoolList.helpers.tsx index ac6f8f2100..737b4245a9 100644 --- a/src/ops-console/pages/SchoolList.helpers.tsx +++ b/src/ops-console/pages/SchoolList.helpers.tsx @@ -149,7 +149,7 @@ export const getSchoolListColumns = (): Column[] => [ label: t('School Performance'), width: '7.78%', align: 'center', - sortable: true, + sortable: false, orderBy: 'school_performance', schoolPerformanceFilterKey: 'schoolPerformance', }, diff --git a/src/ops-console/pages/SidebarPage.tsx b/src/ops-console/pages/SidebarPage.tsx index 6271ba65e8..33a96fe234 100644 --- a/src/ops-console/pages/SidebarPage.tsx +++ b/src/ops-console/pages/SidebarPage.tsx @@ -126,10 +126,17 @@ const SidebarPage: React.FC = () => { RoleType.PROGRAM_MANAGER, ].includes(role as RoleType), ); + const canCreateProgram = userRoles.some((role) => + [RoleType.SUPER_ADMIN, RoleType.OPERATIONAL_DIRECTOR].includes( + role as RoleType, + ), + ); const canAccessCampaignPage = userRoles.some((role) => CAMPAIGN_ACCESS_ROLES.includes(role as RoleType), ); - const canAccessRequestPage = userRoles.includes(RoleType.FIELD_COORDINATOR); + const canAccessCoordinatorPages = userRoles.includes( + RoleType.FIELD_COORDINATOR, + ); useEffect(() => { fetchData(); @@ -147,6 +154,7 @@ const SidebarPage: React.FC = () => { const requestDetailsPrefix = `${requestListPath}/`; const devicesPath = `${path}${PAGES.ADMIN_DEVICES}`; const resourcesPath = `${path}${PAGES.ADMIN_RESOURCES}`; + const dashboardPath = `${path}${PAGES.ADMIN_DASHBOARD}`; const isAllowedPath = location.pathname === schoolListPath || location.pathname.startsWith(schoolDetailsPrefix) || @@ -154,11 +162,12 @@ const SidebarPage: React.FC = () => { (location.pathname === campaignsPath || location.pathname === campaignCreatePath || location.pathname.startsWith(campaignDetailsPrefix))) || - (canAccessRequestPage && + (canAccessCoordinatorPages && (location.pathname === requestListPath || - location.pathname.startsWith(requestDetailsPrefix))) || - location.pathname === devicesPath || - location.pathname === resourcesPath; + location.pathname.startsWith(requestDetailsPrefix) || + location.pathname === devicesPath || + location.pathname === resourcesPath || + location.pathname === dashboardPath)); if (!isAllowedPath) { history.replace(schoolListPath); @@ -166,7 +175,7 @@ const SidebarPage: React.FC = () => { }, [ canAccessCampaignPage, canAccessProgramPage, - canAccessRequestPage, + canAccessCoordinatorPages, history, isExternalUser, location.pathname, @@ -338,7 +347,11 @@ const SidebarPage: React.FC = () => { - + {canCreateProgram ? ( + + ) : ( + + )} diff --git a/src/pages/SelectMode.test.tsx b/src/pages/SelectMode.test.tsx index 6326b97d51..f48b67d8c4 100644 --- a/src/pages/SelectMode.test.tsx +++ b/src/pages/SelectMode.test.tsx @@ -70,6 +70,12 @@ jest.mock('./assets/leftArrowIcon.svg', () => ({ })); const mockHistoryReplace = jest.fn(); +let mockLocationState: + | { + fromKidsAppLocationSchool?: boolean; + fromSchoolModeSwitchProfile?: boolean; + } + | undefined; jest.mock('react-router', () => { const actual = jest.requireActual('react-router'); return { @@ -77,6 +83,9 @@ jest.mock('react-router', () => { useHistory: () => ({ replace: mockHistoryReplace, }), + useLocation: () => ({ + state: mockLocationState, + }), }; }); // Move i18n mock to top of file, before importing SelectMode @@ -314,6 +323,7 @@ describe('SelectMode page', () => { jest.clearAllMocks(); localStorage.clear(); sessionStorage.clear(); + mockLocationState = undefined; // Mock Redux hooks useAppDispatch.mockReturnValue(jest.fn()); @@ -552,9 +562,6 @@ describe('SelectMode page', () => { mockApiHandler.getSchoolsForUser.mockResolvedValue([ { school: { id: 'school-1', name: 'School 1' }, role: 'AUTOUSER' }, ]); - mockApiHandler.getSchoolsWithRoleAutouser.mockResolvedValue([ - { id: 'school-1' }, - ]); mockApiHandler.getSchoolsWithRoleAutouser.mockResolvedValue([]); render(); @@ -877,6 +884,76 @@ describe('SelectMode page', () => { expect(screen.queryByText('Teacher School')).not.toBeInTheDocument(); }); + it('keeps explicit kids school entry in school flow for teacher-role users', async () => { + const teacherSchool = { id: 'school-1', name: 'Teacher School' }; + const classDoc = { + id: 'class-1', + name: 'Class 1', + school_id: teacherSchool.id, + }; + mockLocationState = { fromKidsAppLocationSchool: true }; + mockGetCurrMode.mockResolvedValue(MODES.TEACHER_SCHOOL); + mockAuthHandler.getCurrentUser.mockResolvedValue({ + id: 'user-1', + name: 'Teacher User', + }); + mockApiHandler.getSchoolsForUser.mockResolvedValue([ + { school: teacherSchool, role: 'TEACHER' }, + ]); + mockApiHandler.getSchoolsWithRoleAutouser.mockResolvedValue([]); + mockApiHandler.getClassesForSchool.mockResolvedValue([classDoc]); + mockApiHandler.getStudentsForClass.mockResolvedValue([ + { id: 'student-1', name: 'Student 1' }, + ]); + + render(); + + await waitFor(() => { + expect(mockApiHandler.getClassesForSchool).toHaveBeenCalledWith( + teacherSchool.id, + 'user-1', + ); + }); + expect(mockSetCurrMode).not.toHaveBeenCalledWith(MODES.TEACHER); + expect(mockHistoryReplace).not.toHaveBeenCalledWith(PAGES.HOME_PAGE); + expect(mockHistoryReplace).not.toHaveBeenCalledWith(PAGES.DISPLAY_SCHOOLS); + }); + + it('keeps school-mode switch profile in school flow for teacher-role users', async () => { + const teacherSchool = { id: 'school-1', name: 'Teacher School' }; + const classDoc = { + id: 'class-1', + name: 'Class 1', + school_id: teacherSchool.id, + }; + mockLocationState = { fromSchoolModeSwitchProfile: true }; + mockGetCurrMode.mockResolvedValue(MODES.TEACHER_SCHOOL); + mockAuthHandler.getCurrentUser.mockResolvedValue({ + id: 'user-1', + name: 'Teacher User', + }); + mockApiHandler.getSchoolsForUser.mockResolvedValue([ + { school: teacherSchool, role: 'TEACHER' }, + ]); + mockApiHandler.getSchoolsWithRoleAutouser.mockResolvedValue([]); + mockApiHandler.getClassesForSchool.mockResolvedValue([classDoc]); + mockApiHandler.getStudentsForClass.mockResolvedValue([ + { id: 'student-1', name: 'Student 1' }, + ]); + + render(); + + await waitFor(() => { + expect(mockApiHandler.getClassesForSchool).toHaveBeenCalledWith( + teacherSchool.id, + 'user-1', + ); + }); + expect(mockSetCurrMode).not.toHaveBeenCalledWith(MODES.TEACHER); + expect(mockHistoryReplace).not.toHaveBeenCalledWith(PAGES.HOME_PAGE); + expect(mockHistoryReplace).not.toHaveBeenCalledWith(PAGES.DISPLAY_SCHOOLS); + }); + it('uses the school picker when multiple teacher schools remain after stored school is removed', async () => { const removedPrincipalSchool = { id: 'school-removed', diff --git a/src/pages/SelectMode.tsx b/src/pages/SelectMode.tsx index cd6cb9d316..6df066e11f 100644 --- a/src/pages/SelectMode.tsx +++ b/src/pages/SelectMode.tsx @@ -5,7 +5,7 @@ import { t } from 'i18next'; import { FC, useEffect, useMemo, useState } from 'react'; import { GiTeacher } from 'react-icons/gi'; import { IoMdPeople } from 'react-icons/io'; -import { useHistory } from 'react-router'; +import { useHistory, useLocation } from 'react-router'; import { AVATARS, CURRENT_CLASS_NAME, @@ -99,6 +99,11 @@ interface SchoolModeOption { role: RoleType; } +interface SelectModeLocationState { + fromKidsAppLocationSchool?: boolean; + fromSchoolModeSwitchProfile?: boolean; +} + const SUPPORTED_LANGUAGE_CODES = new Set(Object.values(LANG)); const getLanguageCodeForId = async ( @@ -212,6 +217,7 @@ const SelectMode: FC = () => { const api = ServiceConfig.getI().apiHandler; const auth = ServiceConfig.getI().authHandler; const history = useHistory(); + const location = useLocation(); const { setGbUpdated } = useGbContext(); const [stage, setStage] = useState(STAGES.MODE); const [isOkayButtonDisabled, setIsOkayButtonDisabled] = useState(true); @@ -443,7 +449,15 @@ const SelectMode: FC = () => { })); setTeacherAppSchoolList(teacherAppSchoolOptions); - if (teacherRoleEntries.length > 0) { + const shouldSuppressTeacherAutoEntry = + currentMode === MODES.TEACHER_SCHOOL && + (location.state?.fromKidsAppLocationSchool === true || + location.state?.fromSchoolModeSwitchProfile === true); + const shouldAutoEnterTeacherApp = + teacherRoleEntries.length > 0 && !shouldSuppressTeacherAutoEntry; + const shouldUseEmptySchoolFallback = !shouldSuppressTeacherAutoEntry; + + if (shouldAutoEnterTeacherApp) { await applyOrientationForMode(MODES.TEACHER); schoolUtil.setCurrMode(MODES.TEACHER); @@ -581,7 +595,7 @@ const SelectMode: FC = () => { } } else if (allSchool.length === 0) { onParentSelect(); - } else if (teacherRoleEntries.length === 1) { + } else if (shouldAutoEnterTeacherApp && teacherRoleEntries.length === 1) { const fallbackTeacherSchool = teacherRoleEntries[0]; setCurrentSchool(fallbackTeacherSchool.school); setCurrentSchoolRole(fallbackTeacherSchool.role); @@ -592,12 +606,12 @@ const SelectMode: FC = () => { ); setCurrentSchoolName(fallbackTeacherSchool.school.name); setStage(STAGES.MODE); - } else if (teacherRoleEntries.length > 1) { + } else if (shouldAutoEnterTeacherApp && teacherRoleEntries.length > 1) { await applyOrientationForMode(MODES.TEACHER); schoolUtil.setCurrMode(MODES.TEACHER); history.replace(PAGES.DISPLAY_SCHOOLS); return; - } else { + } else if (shouldUseEmptySchoolFallback) { // Teacher logic await applyOrientationForMode(MODES.TEACHER); schoolUtil.setCurrMode(MODES.TEACHER); diff --git a/src/services/api/ApiHandler.ts b/src/services/api/ApiHandler.ts index a28d6248db..0c4e862efc 100644 --- a/src/services/api/ApiHandler.ts +++ b/src/services/api/ApiHandler.ts @@ -1604,6 +1604,12 @@ export class ApiHandler implements ServiceApi { return await this.s.getCampaignAudienceOptions(programId); } + public async getCampaignGradesForSchools( + schoolIds: string[], + ): Promise { + return await this.s.getCampaignGradesForSchools(schoolIds); + } + public async getCampaignAudienceSummary( params: CampaignAudienceSummaryParams, ): Promise { @@ -1917,7 +1923,7 @@ export class ApiHandler implements ServiceApi { return await this.s.getClassesBySchoolId(schoolId); } - public async getParentWhatsappClassesBySchoolId(schoolId: string): Promise< + public async getParentWhatsappClassesBySchoolId(schoolIds: string[]): Promise< { id: string; name: string; @@ -1930,7 +1936,7 @@ export class ApiHandler implements ServiceApi { 'Parent WhatsApp class lookup is not implemented in current API service.', ); } - return await this.s.getParentWhatsappClassesBySchoolId(schoolId); + return await this.s.getParentWhatsappClassesBySchoolId(schoolIds); } public async getParentWhatsappParentPhonesByClassId( @@ -1943,6 +1949,18 @@ export class ApiHandler implements ServiceApi { } return await this.s.getParentWhatsappParentPhonesByClassId(classId); } + + public async getCampaignParentsInGroupBySchoolIds( + schoolIds: string[], + ): Promise { + if (!this.s.getCampaignParentsInGroupBySchoolIds) { + throw new Error( + 'Campaign parents-in-group metrics lookup is not implemented in current API service.', + ); + } + return await this.s.getCampaignParentsInGroupBySchoolIds(schoolIds); + } + public async createAutoProfile( languageDocId: string | undefined, tcVersion: number, diff --git a/src/services/api/ServiceApi.ts b/src/services/api/ServiceApi.ts index 88f401d38d..ded82c7450 100644 --- a/src/services/api/ServiceApi.ts +++ b/src/services/api/ServiceApi.ts @@ -237,6 +237,8 @@ export type CampaignTargetType = 'percentage_completion' | 'number_of_lessons'; export type CampaignRewardType = 'digital_rewards' | 'physical_rewards'; +export type CampaignFrequency = 'daily' | 'alternate_days' | 'alternate_week'; + export type CampaignOption = { id: string; name: string; @@ -296,6 +298,7 @@ export type CampaignAudiencePayload = { export type CreateCampaignSetupPayload = CampaignAudiencePayload & { campaignName: string; + frequency: CampaignFrequency; objective: CampaignObjective; targetType?: CampaignTargetType; targetValue?: number; @@ -457,8 +460,13 @@ export type CampaignAssignmentSummaryRow = { lessonName: string; }; +export type CampaignAssignmentUniqueSubject = CampaignOption & { + gradeIds: string[]; +}; + export type CampaignAssignmentsResponse = { assignments: CampaignAssignmentSummaryRow[]; + uniqueSubjects: CampaignAssignmentUniqueSubject[]; total: number; }; @@ -2428,6 +2436,13 @@ export interface ServiceApi { programId: string, ): Promise; + /** + * Loads grades available for the selected schools. + * Grades are derived from active classes linked to those schools. + * @param {string[]} schoolIds - Selected school IDs. + */ + getCampaignGradesForSchools(schoolIds: string[]): Promise; + /** * Returns a grade-wise student count summary for the selected schools and grades. * Used by the campaign setup audience summary box. @@ -2873,8 +2888,8 @@ export interface ServiceApi { getClassesBySchoolId(schoolId: string): Promise[]>; - // Parent WhatsApp Invitation: lightweight class lookup for invite workflow. - getParentWhatsappClassesBySchoolId?: (schoolId: string) => Promise< + // Parent WhatsApp Invitation: lightweight class lookup for selected schools. + getParentWhatsappClassesBySchoolId?: (schoolIds: string[]) => Promise< { id: string; name: string; @@ -2888,6 +2903,13 @@ export interface ServiceApi { classId: string, ) => Promise; + /** + * Returns the seven-day parents-in-group total for selected campaign schools. + */ + getCampaignParentsInGroupBySchoolIds?: ( + schoolIds: string[], + ) => Promise; + /** * Creates a auto student profile for a parent and returns the student object * @param {string} languageDocId - languageDocId is `Language` doc id diff --git a/src/services/api/SqliteApi.ts b/src/services/api/SqliteApi.ts index d751226430..0b6b9e6807 100644 --- a/src/services/api/SqliteApi.ts +++ b/src/services/api/SqliteApi.ts @@ -4498,9 +4498,13 @@ export class SqliteApi implements ServiceApi { const query = ` SELECT a.* FROM ${TABLES.Assignment} a - LEFT JOIN ${TABLES.Assignment_user} au ON a.id = au.assignment_id + LEFT JOIN ${TABLES.Assignment_user} au + ON a.id = au.assignment_id + AND au.user_id = "${studentId}" + AND au.is_deleted = 0 LEFT JOIN result r ON a.id = r.assignment_id AND r.student_id = "${studentId}" WHERE a.class_id = '${classId}' + AND a.is_deleted = 0 AND (a.is_class_wise = 1 OR au.user_id = "${studentId}") AND r.assignment_id IS NULL AND a.type <> 'assessment' @@ -5809,9 +5813,16 @@ export class SqliteApi implements ServiceApi { const query = ` SELECT a.* FROM ${TABLES.Assignment} a - LEFT JOIN ${TABLES.Assignment_user} au ON a.id = au.assignment_id + LEFT JOIN ${TABLES.Assignment_user} au + ON a.id = au.assignment_id + AND au.user_id = '${studentId}' + AND au.is_deleted = 0 LEFT JOIN result r ON a.id = r.assignment_id AND r.student_id = '${studentId}' - WHERE a.lesson_id = '${lessonId}' AND a.class_id = '${classId}' and (a.is_class_wise = 1 or au.user_id = '${studentId}') and r.assignment_id IS NULL + WHERE a.lesson_id = '${lessonId}' + AND a.class_id = '${classId}' + AND a.is_deleted = 0 + AND (a.is_class_wise = 1 or au.user_id = '${studentId}') + AND r.assignment_id IS NULL ORDER BY a.updated_at DESC LIMIT 1; `; @@ -7307,7 +7318,7 @@ order by } // Parent WhatsApp Invitation: class lookup with group/invite fields. - async getParentWhatsappClassesBySchoolId(schoolId: string): Promise< + async getParentWhatsappClassesBySchoolId(schoolIds: string[]): Promise< { id: string; name: string; @@ -7320,7 +7331,7 @@ order by 'Parent WhatsApp class lookup is not implemented in Supabase API.', ); } - return await this._serverApi.getParentWhatsappClassesBySchoolId(schoolId); + return await this._serverApi.getParentWhatsappClassesBySchoolId(schoolIds); } // Parent WhatsApp Invitation: parent phones from class_user -> user join. @@ -8021,6 +8032,12 @@ order by return await this._serverApi.getCampaignAudienceOptions(programId); } + async getCampaignGradesForSchools( + schoolIds: string[], + ): Promise { + return await this._serverApi.getCampaignGradesForSchools(schoolIds); + } + async getCampaignAudienceSummary( params: CampaignAudienceSummaryParams, ): Promise { diff --git a/src/services/api/SupabaseApi.ts b/src/services/api/SupabaseApi.ts index 40eab96364..676df97e0c 100644 --- a/src/services/api/SupabaseApi.ts +++ b/src/services/api/SupabaseApi.ts @@ -392,6 +392,8 @@ const CAMPAIGN_LISTING_NATIVE_SORT_COLUMNS: Partial< endDate: 'end_date', }; +const CAMPAIGN_REACH_METRIC_WINDOW = '7d'; + const isCampaignListingRelationSort = ( orderBy: NonNullable, ) => orderBy === 'manager' || orderBy === 'programName'; @@ -404,11 +406,6 @@ type CampaignClassGradeRow = Pick, 'id' | 'grade_id'> & { grade?: CampaignGradeRow | CampaignGradeRow[] | null; }; -type CampaignClassUserRow = Pick< - TableTypes<'class_user'>, - 'class_id' | 'user_id' ->; - type CampaignSchoolCourseGradeRow = { course?: | { @@ -4106,11 +4103,57 @@ export class SupabaseApi implements ServiceApi { return (allSchools ?? []).map((school) => ({ school, role })); } - // --- PROGRAM MANAGER / FIELD COORDINATOR --- - if ( - role === RoleType.PROGRAM_MANAGER || - role === RoleType.FIELD_COORDINATOR - ) { + // --- FIELD COORDINATOR --- + if (role === RoleType.FIELD_COORDINATOR) { + const { data: schoolUsers, error: schoolUserErr } = await this.supabase + .from(TABLES.SchoolUser) + .select('role, school:school_id(*)') + .eq('user_id', userId) + .eq('role', RoleType.FIELD_COORDINATOR) + .eq('is_deleted', false); + + if (schoolUserErr) { + logger.error( + 'Error fetching field coordinator school_user rows:', + schoolUserErr, + ); + return []; + } + + const unique = new Map< + string, + { school: TableTypes<'school'>; role: RoleType } + >(); + for (const row of schoolUsers ?? []) { + const school = firstOrSelf(row.school); + if ( + !school?.id || + school.is_deleted || + (search && + !String(school.name ?? '') + .toLowerCase() + .includes(search.toLowerCase())) + ) { + continue; + } + + unique.set(String(school.id), { + school: school as TableTypes<'school'>, + role, + }); + } + + return Array.from(unique.values()) + .sort((a, b) => + String(a.school.name ?? '').localeCompare( + String(b.school.name ?? ''), + ), + ) + .slice(from, to + 1); + } + + // --- PROGRAM MANAGER --- + if (role === RoleType.PROGRAM_MANAGER) { const { data: progUsers, error: puErr } = await this.supabase .from(TABLES.ProgramUser) .select('program_id') @@ -4339,7 +4382,7 @@ export class SupabaseApi implements ServiceApi { } // Parent WhatsApp Invitation: class lookup with group and invite fields. - async getParentWhatsappClassesBySchoolId(schoolId: string): Promise< + async getParentWhatsappClassesBySchoolId(schoolIds: string[]): Promise< { id: string; name: string; @@ -4347,17 +4390,18 @@ export class SupabaseApi implements ServiceApi { whatsapp_invite_link?: string | null; }[] > { - if (!this.supabase) return []; + if (!this.supabase || schoolIds.length === 0) return []; + const uniqueSchoolIds = Array.from(new Set(schoolIds)); const { data, error } = await this.supabase .from(TABLES.Class) .select('id, name, group_id, whatsapp_invite_link') - .eq('school_id', schoolId) + .in('school_id', uniqueSchoolIds) .eq('is_deleted', false); if (error) { logger.error( - 'Error in parent WhatsApp class lookup by school ID:', + 'Error in parent WhatsApp class lookup by school IDs:', error, ); throw error; @@ -4403,6 +4447,33 @@ export class SupabaseApi implements ServiceApi { return Array.from(phoneSet); } + async getCampaignParentsInGroupBySchoolIds( + schoolIds: string[], + ): Promise { + if (!this.supabase || schoolIds.length === 0) return 0; + + const uniqueSchoolIds = Array.from(new Set(schoolIds)); + const { data, error } = await this.supabase + .from(TABLES.SchoolMetrics) + .select('school_id, parents_in_group') + .in('school_id', uniqueSchoolIds) + .eq('metric_window', CAMPAIGN_REACH_METRIC_WINDOW) + .eq('is_deleted', false); + + if (error) { + logger.error( + 'Error fetching campaign parents-in-group school metrics:', + error, + ); + throw error; + } + + return (data ?? []).reduce( + (total, row) => total + (row.parents_in_group ?? 0), + 0, + ); + } + async getUsersByIds(userIds: string[]): Promise[]> { if (!this.supabase || userIds.length === 0) return []; @@ -9939,16 +10010,14 @@ export class SupabaseApi implements ServiceApi { return { data: [], totalCount: 0 }; } - const shouldUseDatabasePagination = Boolean( - nativeSortColumn || shouldUseRelationSort, - ); + const shouldUseDatabasePagination = Boolean(nativeSortColumn); const searchRelationSelect = normalizedSearchTerm.length > 0 ? `, manager_search:user!campaign_manager_id_fkey(), program_search:program!campaign_program_id_fkey()` : ''; - const campaignListingSelect = `id, name, objective, start_date, end_date, + const campaignListingSelect = `id, name, objective, start_date, end_date, frequency, campaign_status, manager_id, program_id, target_audience_id, created_at, updated_at, is_deleted, target_type, target_value, rewards, manager:user!campaign_manager_id_fkey(id, name), @@ -9993,16 +10062,6 @@ export class SupabaseApi implements ServiceApi { campaignQuery.order(nativeSortColumn, { ascending: orderDir === 'asc', }); - } else if (orderBy === 'manager') { - campaignQuery.order('name', { - ascending: orderDir === 'asc', - foreignTable: TABLES.User, - }); - } else if (orderBy === 'programName') { - campaignQuery.order('name', { - ascending: orderDir === 'asc', - foreignTable: TABLES.Program, - }); } campaignQuery @@ -10280,6 +10339,7 @@ export class SupabaseApi implements ServiceApi { .from(TABLES.Assignment) .update({ is_deleted: true, + ends_at: nowIso, updated_at: nowIso, }) .eq('campaign_id', campaignId) @@ -10376,13 +10436,19 @@ export class SupabaseApi implements ServiceApi { new Set(schools.map((school) => school.block).filter(Boolean)), ).sort((a, b) => a.localeCompare(b)); - const grades = await this.getCampaignGradesForSchools( + const grades = await this.getCampaignAudienceOptionGradesForSchools( schools.map((school) => school.id), ); return { blocks, schools, grades }; } + async getCampaignGradesForSchools( + schoolIds: string[], + ): Promise { + return await this.fetchDistinctClassGradesForSchools(schoolIds); + } + async getCampaignAudienceSummary({ schoolIds, gradeIds, @@ -10391,93 +10457,20 @@ export class SupabaseApi implements ServiceApi { return { totalStudents: 0, grades: [] }; } - const { data: classRows, error: classError } = await this.supabase - .from('class') - .select('id, grade_id, grade:grade_id(id, name, sort_index)') - .in('school_id', schoolIds) - .in('grade_id', gradeIds) - .eq('is_deleted', false); + const { data, error } = await this.supabase.rpc( + 'get_campaign_audience_summary', + { + p_school_ids: schoolIds, + p_grade_ids: gradeIds, + }, + ); - if (classError) { - logger.error('Error fetching campaign summary classes:', classError); + if (error) { + logger.error('Error fetching campaign audience summary:', error); return { totalStudents: 0, grades: [] }; } - const classGradeMap = new Map< - string, - { gradeId: string; gradeName: string; sort: number } - >(); - - ((classRows ?? []) as CampaignClassGradeRow[]).forEach((row) => { - const grade = firstOrSelf(row.grade); - if (!row.id || !row.grade_id || !grade?.name) return; - classGradeMap.set(String(row.id), { - gradeId: String(row.grade_id), - gradeName: String(grade.name), - sort: Number(grade.sort_index ?? 9999), - }); - }); - - const classIds = Array.from(classGradeMap.keys()); - if (classIds.length === 0) return { totalStudents: 0, grades: [] }; - - const classUserRows: CampaignClassUserRow[] = []; - for (const classIdBatch of chunkArray(classIds, 500)) { - const { data, error: classUserError } = await this.supabase - .from('class_user') - .select('class_id, user_id') - .in('class_id', classIdBatch) - .eq('role', RoleType.STUDENT) - .eq('is_deleted', false); - - if (classUserError) { - logger.error( - 'Error fetching campaign summary class users:', - classUserError, - ); - return { totalStudents: 0, grades: [] }; - } - - classUserRows.push(...((data ?? []) as CampaignClassUserRow[])); - } - - const studentsByGrade = new Map>(); - const gradeMeta = new Map(); - - (classUserRows ?? []).forEach((row) => { - const classMeta = classGradeMap.get(String(row.class_id)); - if (!classMeta || !row.user_id) return; - if (!studentsByGrade.has(classMeta.gradeId)) { - studentsByGrade.set(classMeta.gradeId, new Set()); - gradeMeta.set(classMeta.gradeId, { - gradeName: classMeta.gradeName, - sort: classMeta.sort, - }); - } - studentsByGrade.get(classMeta.gradeId)?.add(String(row.user_id)); - }); - - const grades = Array.from(studentsByGrade.entries()) - .map(([gradeId, students]) => ({ - gradeId, - gradeName: gradeMeta.get(gradeId)?.gradeName ?? 'Grade', - sort: gradeMeta.get(gradeId)?.sort ?? 9999, - studentCount: students.size, - })) - .sort((a, b) => a.sort - b.sort || a.gradeName.localeCompare(b.gradeName)) - .map(({ gradeId, gradeName, studentCount }) => ({ - gradeId, - gradeName, - studentCount, - })); - - return { - totalStudents: grades.reduce( - (total, grade) => total + grade.studentCount, - 0, - ), - grades, - }; + return data ?? { totalStudents: 0, grades: [] }; } async createCampaignAudienceGroup( @@ -10517,6 +10510,7 @@ export class SupabaseApi implements ServiceApi { manager_id: payload.managerId, start_date: payload.startDate, end_date: payload.endDate, + frequency: payload.frequency, rewards: payload.rewards ? JSON.stringify(payload.rewards) : null, }; @@ -10857,10 +10851,28 @@ export class SupabaseApi implements ServiceApi { if (!this.supabase || !campaignId) { return { assignments: [], + uniqueSubjects: [], total: 0, }; } + type CampaignAssignmentRpcRow = { + assignment_id: string; + assignment_date: string; + grade_id: string; + grade_name: string; + subject_id: string; + subject_name: string; + lesson_id: string; + lesson_name: string; + unique_subjects?: Array<{ + subject_id: string; + subject_name: string; + grade_ids?: string[] | null; + }> | null; + total_count: string | number; + }; + const { data, error } = await this.supabase.rpc( 'get_campaign_assignments', { @@ -10880,9 +10892,21 @@ export class SupabaseApi implements ServiceApi { throw error; } + const rpcRows = data as CampaignAssignmentRpcRow[] | null | undefined; + const firstRow = rpcRows?.[0]; + const uniqueSubjects = Array.isArray(firstRow?.unique_subjects) + ? firstRow.unique_subjects.map((subject) => ({ + id: String(subject.subject_id), + name: String(subject.subject_name), + gradeIds: Array.isArray(subject.grade_ids) + ? subject.grade_ids.map(String) + : [], + })) + : []; + return { assignments: - data?.map((row) => ({ + rpcRows?.map((row) => ({ assignmentId: row.assignment_id, assignmentDate: row.assignment_date, @@ -10895,7 +10919,8 @@ export class SupabaseApi implements ServiceApi { lessonId: row.lesson_id, lessonName: row.lesson_name, })) ?? [], - total: data?.length ? Number(data[0].total_count) : 0, + uniqueSubjects, + total: rpcRows?.length ? Number(firstRow?.total_count ?? 0) : 0, }; } @@ -11037,7 +11062,36 @@ export class SupabaseApi implements ServiceApi { }; } - private async getCampaignGradesForSchools( + private async fetchDistinctClassGradesForSchools( + schoolIds: string[], + ): Promise<{ id: string; name: string }[]> { + if (!this.supabase || schoolIds.length === 0) return []; + + const { data: gradeRows, error: gradeError } = await this.supabase + .from('grade') + .select('id, name, sort_index, class!inner()') + .in('class.school_id', schoolIds) + .eq('class.is_deleted', false) + .eq('is_deleted', false); + + if (gradeError) { + logger.error('Error fetching school grades for campaign:', gradeError); + } + + return ((gradeRows ?? []) as CampaignGradeRow[]) + .filter((grade) => grade.id && grade.name) + .sort( + (a, b) => + Number(a.sort_index ?? 9999) - Number(b.sort_index ?? 9999) || + String(a.name).localeCompare(String(b.name)), + ) + .map((grade) => ({ + id: String(grade.id), + name: String(grade.name), + })); + } + + private async getCampaignAudienceOptionGradesForSchools( schoolIds: string[], ): Promise<{ id: string; name: string }[]> { if (!this.supabase || schoolIds.length === 0) return []; @@ -12700,15 +12754,18 @@ export class SupabaseApi implements ServiceApi { : tab !== PROGRAM_TAB.ALL ? [tab] : []; - const listFilters: Array<[string, string[] | undefined]> = [ - ['program_model', modelFilters], + const modelFilter = buildListFilter('program_model', modelFilters); + if (modelFilter) query = query.or(modelFilter); + + // Array-backed columns must use PostgreSQL's overlap operator. Applying + // ilike to these text[] fields fails with error 42883. + const arrayFilters: Array<[string, string[] | undefined]> = [ ['partners', cleanedFilters.partner], ['program_managers', cleanedFilters.programManager], ['field_coordinators', cleanedFilters.fieldCoordinator], ]; - listFilters.forEach(([column, values]) => { - const filter = buildListFilter(column, values); - if (filter) query = query.or(filter); + arrayFilters.forEach(([column, values]) => { + if (values?.length) query = query.overlaps(column, values); }); // Needed to apply simple scalar filters at the database layer. @@ -12730,7 +12787,14 @@ export class SupabaseApi implements ServiceApi { } } - const percentFilters: Array<[string, string]> = [ + type ProgramPercentColumn = + | 'onboarded_students_pct' + | 'activated_students_pct' + | 'active_students_pct' + | 'onboarded_teachers_pct' + | 'activated_teachers_pct' + | 'active_teachers_pct'; + const percentFilters: Array<[ProgramPercentColumn, string]> = [ ['onboarded_students_pct', 'onboardedStudentsPct'], ['activated_students_pct', 'activatedStudentsPct'], ['active_students_pct', 'activeStudentsPct'], @@ -12738,16 +12802,31 @@ export class SupabaseApi implements ServiceApi { ['activated_teachers_pct', 'activatedTeachersPct'], ['active_teachers_pct', 'activeTeachersPct'], ]; - percentFilters.forEach(([column, filterKey]) => { - const bands = cleanedFilters[filterKey] ?? []; - const conditions = bands.flatMap((band) => { - if (band === 'Low') return [`${column}.lt.31`]; - if (band === 'Mid') return [`and(${column}.gte.31,${column}.lt.70)`]; - if (band === 'High') return [`${column}.gte.70`]; - return []; - }); - if (conditions.length > 0) query = query.or(conditions.join(',')); - }); + const activePercentFilters = percentFilters + .map( + ([column, filterKey]) => + [ + column, + (cleanedFilters[filterKey] ?? []).filter((band) => + ['Low', 'Mid', 'High'].includes(band), + ), + ] as const, + ) + .filter(([, bands]) => bands.length > 0); + const requiresCalculatedPercentageFiltering = + activePercentFilters.length > 0; + + // Percentage fields are derived from multiple stored counts, so they + // cannot be filtered as program_metrics columns through PostgREST. + const isProgramPercentWithinBand = ( + percent: number | null | undefined, + band: string, + ): boolean => { + if (percent == null) return false; + if (band === 'Low') return percent < 31; + if (band === 'Mid') return percent >= 31 && percent < 70; + return percent >= 70; + }; // Needed to keep search behavior consistent across program and location fields. if (search) { @@ -12781,21 +12860,38 @@ export class SupabaseApi implements ServiceApi { const from = Math.max(Math.trunc(page) - 1, 0) * normalizedPageSize; const to = from + normalizedPageSize - 1; - // Filtering, ordering, counting, and range are applied before execution - // so only the requested page is transferred from Supabase. - const { data, error, count } = await query - .order(safeOrderBy, { ascending: order_dir === 'asc' }) - .range(from, to); + const orderedQuery = query.order(safeOrderBy, { + ascending: order_dir === 'asc', + }); + const pagedQuery = requiresCalculatedPercentageFiltering + ? orderedQuery + : orderedQuery.range(from, to); + const { data, error, count } = await pagedQuery; if (error) { logger.error('Error fetching program_metrics listing:', error); return { data: [], total: 0 }; } + const mappedRows = ((data ?? []) as ProgramMetricsTableRow[]).map((row) => + mapProgramMetricsRow(row), + ); + const filteredRows = requiresCalculatedPercentageFiltering + ? mappedRows.filter((row) => + activePercentFilters.every(([column, bands]) => + bands.some((band) => + isProgramPercentWithinBand(row[column], band), + ), + ), + ) + : mappedRows; + return { - data: ((data ?? []) as ProgramMetricsTableRow[]).map((row) => - mapProgramMetricsRow(row), - ), - total: count ?? 0, + data: requiresCalculatedPercentageFiltering + ? filteredRows.slice(from, to + 1) + : filteredRows, + total: requiresCalculatedPercentageFiltering + ? filteredRows.length + : (count ?? 0), }; } catch (error) { logger.error('Unexpected error in getProgramsFromProgramMetrics:', error); diff --git a/src/services/auth/SupabaseAuth.ts b/src/services/auth/SupabaseAuth.ts index b7942f8e6b..5917e5bd2f 100644 --- a/src/services/auth/SupabaseAuth.ts +++ b/src/services/auth/SupabaseAuth.ts @@ -71,9 +71,8 @@ export class SupabaseAuth implements ServiceAuth { has_session: !!session, has_refresh_token: !!session?.refresh_token, }); - if (event === 'TOKEN_REFRESHED') { - if (session?.refresh_token) - Util.addRefreshTokenToStore(session?.refresh_token); + if (session?.refresh_token) { + Util.addRefreshTokenToStore(session.refresh_token); } }, ); @@ -476,6 +475,7 @@ export class SupabaseAuth implements ServiceAuth { Util.addRefreshTokenToStore( currentSession.data.session.refresh_token, ); + return; } if (currentSession?.error) { logger.error( @@ -483,7 +483,36 @@ export class SupabaseAuth implements ServiceAuth { currentSession.error, ); } + + const stored = Util.getRefreshTokenFromStore(); + if (!stored?.token) return; + + const refreshToken = String(stored.token).replace(/"/g, ''); + const response = await this._auth?.refreshSession({ + refresh_token: refreshToken, + }); + if (response?.error) { + throw new Error( + 'Web session refresh failed: ' + response.error.message, + ); + } + if (response?.data?.session) { + const { access_token, refresh_token } = response.data.session; + await this._auth?.setSession({ access_token, refresh_token }); + Util.addRefreshTokenToStore(refresh_token); + } } catch (error) { + if (this.isInvalidRefreshTokenError(error)) { + store.dispatch(setRefreshToken(null)); + logAuthDebug( + 'Cleared stale web refresh token after refresh failure.', + { + source: 'SupabaseAuth.doRefreshSession', + reason: 'web_invalid_refresh_token', + }, + ); + return; + } logger.error('Unexpected error while resolving web session:', error); } return; diff --git a/src/services/database.ts b/src/services/database.ts index d673ca5582..2ff3f6359f 100644 --- a/src/services/database.ts +++ b/src/services/database.ts @@ -588,6 +588,7 @@ export type Database = { comments: string | null; created_at: string | null; end_date: string; + frequency: 'daily' | 'alternate_days' | 'alternate_week'; id: string; is_deleted: boolean | null; manager_id: string | null; @@ -609,6 +610,7 @@ export type Database = { comments?: string | null; created_at?: string | null; end_date: string; + frequency?: 'daily' | 'alternate_days' | 'alternate_week'; id?: string; is_deleted?: boolean | null; manager_id?: string | null; @@ -630,6 +632,7 @@ export type Database = { comments?: string | null; created_at?: string | null; end_date?: string; + frequency?: 'daily' | 'alternate_days' | 'alternate_week'; id?: string; is_deleted?: boolean | null; manager_id?: string | null; @@ -4349,6 +4352,7 @@ export type Database = { metric_window: string | null; onboarded_students: number | null; partners: string[] | null; + parents_in_group: number | null; parents_reached: number | null; program_id: string | null; program_managers: string[] | null; @@ -4381,6 +4385,7 @@ export type Database = { metric_window?: string | null; onboarded_students?: number | null; partners?: string[] | null; + parents_in_group?: number | null; parents_reached?: number | null; program_id?: string | null; program_managers?: string[] | null; @@ -4413,6 +4418,7 @@ export type Database = { metric_window?: string | null; onboarded_students?: number | null; partners?: string[] | null; + parents_in_group?: number | null; parents_reached?: number | null; program_id?: string | null; program_managers?: string[] | null; @@ -6373,6 +6379,20 @@ export type Database = { Args: { p_class_id: string; p_days: number }; Returns: number; }; + get_campaign_audience_summary: { + Args: { + p_grade_ids: string[]; + p_school_ids: string[]; + }; + Returns: { + grades: { + gradeId: string; + gradeName: string; + studentCount: number; + }[]; + totalStudents: number; + }; + }; get_campaign_dashboard_metrics: { Args: { p_campaign_ids: string[]; @@ -7941,6 +7961,10 @@ export type Database = { subject_name: string; lesson_id: string; lesson_name: string; + unique_subjects: { + subject_id: string; + subject_name: string; + }[]; total_count: number; }[]; }; diff --git a/src/teachers-module/pages/KidsAppLocation.test.tsx b/src/teachers-module/pages/KidsAppLocation.test.tsx index d53512fb75..5aa2b26e0f 100644 --- a/src/teachers-module/pages/KidsAppLocation.test.tsx +++ b/src/teachers-module/pages/KidsAppLocation.test.tsx @@ -140,7 +140,9 @@ describe('KidsAppLocation', () => { ), ); expect(setCurrModeMock).toHaveBeenCalledWith(MODES.TEACHER_SCHOOL); - expect(mockReplace).toHaveBeenCalledWith(PAGES.SELECT_MODE); + expect(mockReplace).toHaveBeenCalledWith(PAGES.SELECT_MODE, { + fromKidsAppLocationSchool: true, + }); }); test('does not show location options when access is blocked', () => { diff --git a/src/teachers-module/pages/KidsAppLocation.tsx b/src/teachers-module/pages/KidsAppLocation.tsx index 97fee43263..94394d29b0 100644 --- a/src/teachers-module/pages/KidsAppLocation.tsx +++ b/src/teachers-module/pages/KidsAppLocation.tsx @@ -84,7 +84,7 @@ const KidsAppLocation: FC = () => { // Clear all school mode data for a completely fresh selection clearSchoolModeData(false); schoolUtil.setCurrMode(MODES.TEACHER_SCHOOL); - history.replace(PAGES.SELECT_MODE); + history.replace(PAGES.SELECT_MODE, { fromKidsAppLocationSchool: true }); }; if (isCheckingAccess || !isAccessAllowed) { diff --git a/src/teachers-module/pages/SubjectSelection.tsx b/src/teachers-module/pages/SubjectSelection.tsx index 30c347b240..1e07d304aa 100644 --- a/src/teachers-module/pages/SubjectSelection.tsx +++ b/src/teachers-module/pages/SubjectSelection.tsx @@ -28,6 +28,13 @@ interface CurriculumWithCourses { courses: TableTypes<'course'>[]; } +const SUBJECT_MODIFICATION_ROLES = [ + RoleType.SUPER_ADMIN, + RoleType.OPERATIONAL_DIRECTOR, + RoleType.PROGRAM_MANAGER, + RoleType.FIELD_COORDINATOR, +]; + const SubjectSelection: React.FC = () => { const [curriculumsWithCourses, setCurriculumsWithCourses] = useState< CurriculumWithCourses[] @@ -118,8 +125,14 @@ const SubjectSelection: React.FC = () => { !currentSchool?.id || !selectedSchoolId || currentSchool.id === selectedSchoolId; - setCanModify(schoolMatches && normalizedRole === RoleType.PRINCIPAL); - }, [currentSchool?.id, paramSchoolId, roleMap]); + const hasSubjectModificationRole = SUBJECT_MODIFICATION_ROLES.some((role) => + roles.includes(role), + ); + setCanModify( + hasSubjectModificationRole || + (schoolMatches && normalizedRole === RoleType.PRINCIPAL), + ); + }, [currentSchool?.id, paramSchoolId, roleMap, roles]); const fetchCurriculumsAndCourses = async ( context: 'school' | 'class',