diff --git a/hat/assets/js/apps/Iaso/routing/common.ts b/hat/assets/js/apps/Iaso/routing/common.ts index 812648be6b..038cd698e0 100644 --- a/hat/assets/js/apps/Iaso/routing/common.ts +++ b/hat/assets/js/apps/Iaso/routing/common.ts @@ -1 +1,27 @@ export const paginationPathParams = ['order', 'pageSize', 'page']; + +export const getNonPrefixedParams = ( + prefix: string, + params: Record, + keysToIgnore: string[] = [], +) => { + const nonPrefixedParams: Record = {}; + Object.keys(params).forEach(key => { + if (!key.startsWith(prefix) && !keysToIgnore.includes(key)) { + nonPrefixedParams[key] = params[key]; + } + }); + return nonPrefixedParams; +}; +export const getPrefixedParams = ( + prefix: string, + params: Record, +) => { + const reportParams: Record = {}; + Object.keys(params).forEach(key => { + if (key.startsWith(prefix)) { + reportParams[key] = params[key]; + } + }); + return reportParams; +}; diff --git a/plugins/polio/api/urls.py b/plugins/polio/api/urls.py index c46d473d7b..8499894984 100644 --- a/plugins/polio/api/urls.py +++ b/plugins/polio/api/urls.py @@ -36,7 +36,8 @@ from plugins.polio.api.vaccines.vaccine_authorization import VaccineAuthorizationViewSet from plugins.polio.tasks.api.create_refresh_preparedness_data import RefreshPreparednessLaucherViewSet from plugins.polio.api.vaccines.supply_chain import VaccineRequestFormViewSet -from plugins.polio.api.vaccines.repository import VaccineRepositoryViewSet +from plugins.polio.api.vaccines.repository_forms import VaccineRepositoryFormsViewSet +from plugins.polio.api.vaccines.repository_reports import VaccineRepositoryReportsViewSet from plugins.polio.api.vaccines.stock_management import ( VaccineStockManagementViewSet, OutgoingStockMovementViewSet, @@ -88,7 +89,11 @@ router.register(r"polio/tasks/refreshim/hh_ohh", RefreshIMAllDataViewset, basename="refreshimhhohh") router.register(r"polio/vaccine/request_forms", VaccineRequestFormViewSet, basename="vaccine_request_forms") router.register(r"polio/vaccine/vaccine_stock", VaccineStockManagementViewSet, basename="vaccine_stocks") -router.register(r"polio/vaccine/repository", VaccineRepositoryViewSet, basename="vaccine_repository") +router.register(r"polio/vaccine/repository", VaccineRepositoryFormsViewSet, basename="vaccine_repository") +router.register( + r"polio/vaccine/repository_reports", VaccineRepositoryReportsViewSet, basename="vaccine_repository_reports" +) + router.register( r"polio/vaccine/stock/outgoing_stock_movement", OutgoingStockMovementViewSet, basename="outgoing_stock_movement" ) diff --git a/plugins/polio/api/vaccines/repository.py b/plugins/polio/api/vaccines/repository_forms.py similarity index 99% rename from plugins/polio/api/vaccines/repository.py rename to plugins/polio/api/vaccines/repository_forms.py index ec33a0a92c..1672e24ef9 100644 --- a/plugins/polio/api/vaccines/repository.py +++ b/plugins/polio/api/vaccines/repository_forms.py @@ -142,7 +142,7 @@ def get_form_a_data(self, obj): ] -class VaccineRepositoryViewSet(GenericViewSet, ListModelMixin): +class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin): """ ViewSet for retrieving vaccine repository data. """ diff --git a/plugins/polio/api/vaccines/repository_reports.py b/plugins/polio/api/vaccines/repository_reports.py new file mode 100644 index 0000000000..f55deb86d7 --- /dev/null +++ b/plugins/polio/api/vaccines/repository_reports.py @@ -0,0 +1,155 @@ +"""API endpoints and serializers for vaccine repository reports.""" + +from datetime import datetime, timedelta +from django.db.models import OuterRef, Subquery, Q, Value, Case, When, CharField, Exists +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema +from rest_framework import filters, permissions, serializers +from rest_framework.exceptions import ValidationError +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.mixins import ListModelMixin +from rest_framework.viewsets import GenericViewSet +from django.db.models import Prefetch + +from iaso.api.common import Paginator +from plugins.polio.models import VaccineStock, DestructionReport, IncidentReport + + +class VaccineReportingFilterBackend(filters.BaseFilterBackend): + """Filter backend for vaccine reporting that handles country and file type filtering.""" + + def filter_queryset(self, request, queryset, view): + # Filter by vaccine name (single) + vaccine_name = request.query_params.get("vaccine_name", None) + if vaccine_name: + queryset = queryset.filter(vaccine=vaccine_name) + + # Filter by country block + country_block = request.query_params.get("country_block", None) + if country_block: + try: + country_block_ids = [int(id) for id in country_block.split(",")] + queryset = queryset.filter(country__groups__in=country_block_ids) + except ValueError: + raise ValidationError("country_block must be a comma-separated list of integers") + + # Filter by country (multi) + countries = request.query_params.get("countries", None) + if countries: + try: + country_ids = [int(id) for id in countries.split(",")] + queryset = queryset.filter(country__id__in=country_ids) + except ValueError: + raise ValidationError("countries must be a comma-separated list of integers") + + # Filter by file type + file_type = request.query_params.get("file_type", None) + if file_type: + try: + filetypes = [tp.strip().upper() for tp in file_type.split(",")] + if "INCIDENT" in filetypes: + queryset = queryset.filter(incidentreport__isnull=False) + if "DESTRUCTION" in filetypes: + queryset = queryset.filter(destructionreport__isnull=False) + except ValueError: + raise ValidationError("file_type must be a comma-separated list of strings") + + return queryset.distinct() + + +class VaccineRepositoryReportSerializer(serializers.Serializer): + country_name = serializers.CharField(source="country.name") + country_id = serializers.IntegerField(source="country.id") + vaccine = serializers.CharField() + incident_report_data = serializers.SerializerMethodField() + destruction_report_data = serializers.SerializerMethodField() + + def get_incident_report_data(self, obj): + return [ + { + "date": ir.date_of_incident_report, + "file": ir.document.url if ir.document else None, + } + for ir in obj.prefetched_incident_reports + ] + + def get_destruction_report_data(self, obj): + return [ + { + "date": dr.destruction_report_date, + "file": dr.document.url if dr.document else None, + } + for dr in obj.prefetched_destruction_reports + ] + + +class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): + """ViewSet for retrieving vaccine repository reports data.""" + + serializer_class = VaccineRepositoryReportSerializer + pagination_class = Paginator + filter_backends = [OrderingFilter, SearchFilter, VaccineReportingFilterBackend] + ordering_fields = ["country__name", "vaccine"] + ordering = ["country__name"] + search_fields = ["country__name", "vaccine"] + permission_classes = [permissions.IsAuthenticatedOrReadOnly] + default_page_size = 10 + + def get_queryset(self): + """Get the queryset for VaccineStock objects.""" + base_qs = VaccineStock.objects.select_related( + "country", + ).filter(Q(incidentreport__isnull=False) | Q(destructionreport__isnull=False)) + + incident_qs = IncidentReport.objects.only( + "vaccine_stock_id", + "date_of_incident_report", + "document", + ) + + destruction_qs = DestructionReport.objects.only( + "vaccine_stock_id", + "destruction_report_date", + "document", + ) + + return base_qs.prefetch_related( + Prefetch("incidentreport_set", queryset=incident_qs, to_attr="prefetched_incident_reports"), + Prefetch("destructionreport_set", queryset=destruction_qs, to_attr="prefetched_destruction_reports"), + ).distinct() + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "file_type", + openapi.IN_QUERY, + description="Filter by file type (IR, DR)", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "country_block", + openapi.IN_QUERY, + description="Filter by country block (comma separated list of org unit group ids)", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "countries", + openapi.IN_QUERY, + description="Filter by countries (comma separated list of country ids)", + type=openapi.TYPE_STRING, + ), + openapi.Parameter( + "vaccine_name", + openapi.IN_QUERY, + description="Filter by vaccine name", + type=openapi.TYPE_STRING, + ), + ] + ) + def list(self, request, *args, **kwargs): + """ + Vaccine Reports Repository + + Return a paginated list of vaccine stocks with their associated incident and destruction reports. + """ + return super().list(request, *args, **kwargs) diff --git a/plugins/polio/js/src/constants/translations/en.json b/plugins/polio/js/src/constants/translations/en.json index 78c95a76f5..a7f60600d0 100644 --- a/plugins/polio/js/src/constants/translations/en.json +++ b/plugins/polio/js/src/constants/translations/en.json @@ -763,6 +763,7 @@ "iaso.polio.preparednessRoundStarted": "Preparedness can't be edited if round already started", "iaso.polio.PREPARING": "Preparing", "iaso.polio.raStatus": "RA Status", + "iaso.polio.reports": "Reports", "iaso.polio.repository.title": "Vaccine Management Repository", "iaso.polio.restoreCampaign": "Restore campaign", "iaso.polio.ROUND1DONE": "Round 1 completed", diff --git a/plugins/polio/js/src/constants/translations/fr.json b/plugins/polio/js/src/constants/translations/fr.json index 8a62c8748f..40cc28a703 100644 --- a/plugins/polio/js/src/constants/translations/fr.json +++ b/plugins/polio/js/src/constants/translations/fr.json @@ -762,6 +762,7 @@ "iaso.polio.preparednessRoundStarted": "La préparation de campagne ne peut être éditée après le début du round", "iaso.polio.PREPARING": "En préparation", "iaso.polio.raStatus": "Statut RA", + "iaso.polio.reports": "Rapports", "iaso.polio.repository.title": "Archives de la gestion des vaccins", "iaso.polio.restoreCampaign": "Restaurer la campagne", "iaso.polio.ROUND1DONE": "Round 1 terminé", diff --git a/plugins/polio/js/src/constants/urls.ts b/plugins/polio/js/src/constants/urls.ts index 0ba38aecc3..5b0166a394 100644 --- a/plugins/polio/js/src/constants/urls.ts +++ b/plugins/polio/js/src/constants/urls.ts @@ -3,6 +3,7 @@ import { extractParams, extractParamsConfig, extractUrls, + paginationPathParamsWithPrefix, } from '../../../../../hat/assets/js/apps/Iaso/constants/urls'; import { paginationPathParams } from '../../../../../hat/assets/js/apps/Iaso/routing/common'; import { @@ -91,11 +92,19 @@ export const polioRouteConfigs: Record = { url: VACCINE_REPOSITORY_BASE_URL, params: [ ...paginationPathParams, + ...paginationPathParamsWithPrefix('report'), 'countries', 'campaignType', 'file_type', 'country_block', 'vaccine_name', + 'campaignStatus', + 'tab', + 'accountId', + 'reportCountries', + 'reportCountryBlock', + 'reportFileType', + 'reportVaccineName', ], }, embeddedCalendar: { @@ -112,11 +121,18 @@ export const polioRouteConfigs: Record = { url: EMBEDDED_VACCINE_REPOSITORY_URL, params: [ ...paginationPathParams, + ...paginationPathParamsWithPrefix('report'), 'countries', 'campaignType', 'file_type', 'country_block', 'vaccine_name', + 'campaignStatus', + 'tab', + 'reportCountries', + 'reportCountryBlock', + 'reportFileType', + 'reportVaccineName', ], }, lqasCountry: { diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx index 58d0536570..7738e57ecb 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx @@ -1,25 +1,20 @@ -import { Box, Typography } from '@mui/material'; +import { Box, Tab, Tabs, Typography } from '@mui/material'; import { - Column, MENU_HEIGHT_WITHOUT_TABS, + useRedirectTo, useSafeIntl, } from 'bluesquare-components'; -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, useState } from 'react'; import { useLocation } from 'react-router-dom'; import TopBar from '../../../../../../../hat/assets/js/apps/Iaso/components/nav/TopBarComponent'; -import { TableWithDeepLink } from '../../../../../../../hat/assets/js/apps/Iaso/components/tables/TableWithDeepLink'; +import { OffLineLangSwitch } from '../../../../../../../hat/assets/js/apps/Iaso/domains/home/components/LangSwitch'; import { useParamsObject } from '../../../../../../../hat/assets/js/apps/Iaso/routing/hooks/useParamsObject'; import { SxStyles } from '../../../../../../../hat/assets/js/apps/Iaso/types/general'; import { baseUrls } from '../../../constants/urls'; -import { - tableDefaults, - useGetVaccineReporting, -} from './hooks/useGetVaccineReporting'; -import { useVaccineRepositoryColumns } from './hooks/useVaccineRepositoryColumns'; +import { Forms } from './forms'; import MESSAGES from './messages'; +import { Reports } from './reports'; import { VaccineRepositoryParams } from './types'; -import { VaccineRepositoryFilters } from './VaccineRepositoryFilters'; -import { OffLineLangSwitch } from '../../../../../../../hat/assets/js/apps/Iaso/domains/home/components/LangSwitch'; const baseUrl = baseUrls.vaccineRepository; const embeddedVaccineRepositoryUrl = baseUrls.embeddedVaccineRepository; @@ -37,17 +32,6 @@ const styles: SxStyles = { // '& td': { padding: 0 }, }, }; -const NOPADDING_CELLS_IDS = ['vrf_data', 'pre_alert_data', 'form_a_data']; - -const getCellProps = cell => { - const { id } = cell.column as Column; - return { - style: { - padding: NOPADDING_CELLS_IDS.includes(id as string) ? 0 : undefined, - verticalAlign: 'top', - }, - }; -}; // Campaigns status filter should be on another ticket with better specs // What about the colors, what does green says ? @@ -62,9 +46,17 @@ export const VaccineRepository: FunctionComponent = () => { const params = useParamsObject( redirectUrl, ) as unknown as VaccineRepositoryParams; + const redirectTo = useRedirectTo(); + const [tab, setTab] = useState(params.tab ?? 'forms'); const { formatMessage } = useSafeIntl(); - const { data, isFetching } = useGetVaccineReporting(params); - const columns = useVaccineRepositoryColumns(); + const handleChangeTab = (newTab: string) => { + setTab(newTab); + const newParams = { + ...params, + tab: newTab, + }; + redirectTo(baseUrl, newParams); + }; return ( <> @@ -97,26 +89,21 @@ export const VaccineRepository: FunctionComponent = () => { )} - - + + handleChangeTab(newtab)} + > + + + + {tab === 'forms' && } + {tab === 'reports' && } ); diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepositoryFilters.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx similarity index 87% rename from plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepositoryFilters.tsx rename to plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx index d6f3712fd9..31670fd7bd 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepositoryFilters.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/Filters.tsx @@ -8,15 +8,15 @@ import React, { useState, } from 'react'; import { FormattedMessage } from 'react-intl'; -import InputComponent from '../../../../../../../hat/assets/js/apps/Iaso/components/forms/InputComponent'; -import { useGetGroupDropdown } from '../../../../../../../hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups'; -import MESSAGES from '../../../constants/messages'; -import { useGetCountries } from '../../../hooks/useGetCountries'; +import InputComponent from '../../../../../../../../hat/assets/js/apps/Iaso/components/forms/InputComponent'; +import { useGetGroupDropdown } from '../../../../../../../../hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups'; +import MESSAGES from '../../../../constants/messages'; +import { useGetCountries } from '../../../../hooks/useGetCountries'; -import { appId } from '../../../constants/app'; -import { useGetFileTypes } from './hooks/useGetFileTypes'; -import { VaccineRepositoryParams } from './types'; -import { defaultVaccineOptions } from '../SupplyChain/constants'; +import { appId } from '../../../../constants/app'; +import { defaultVaccineOptions } from '../../SupplyChain/constants'; +import { useGetFileTypes } from '../hooks/useGetFileTypes'; +import { VaccineRepositoryParams } from '../types'; type Props = { params: VaccineRepositoryParams; @@ -24,10 +24,7 @@ type Props = { redirectUrl: string; }; -export const VaccineRepositoryFilters: FunctionComponent = ({ - params, - redirectUrl, -}) => { +export const Filters: FunctionComponent = ({ params, redirectUrl }) => { const redirectToReplace = useRedirectToReplace(); const [filtersUpdated, setFiltersUpdated] = useState(false); diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetVaccineReporting.ts b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts similarity index 75% rename from plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetVaccineReporting.ts rename to plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts index 4483663407..64ca3b2dad 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetVaccineReporting.ts +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useGetVaccineReporting.ts @@ -1,8 +1,8 @@ import { UseQueryResult } from 'react-query'; -import { useApiParams } from '../../../../../../../../hat/assets/js/apps/Iaso/hooks/useApiParams'; -import { getRequest } from '../../../../../../../../hat/assets/js/apps/Iaso/libs/Api'; -import { useSnackQuery } from '../../../../../../../../hat/assets/js/apps/Iaso/libs/apiHooks'; -import { VaccineReporting } from '../types'; +import { useApiParams } from '../../../../../../../../../hat/assets/js/apps/Iaso/hooks/useApiParams'; +import { getRequest } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/Api'; +import { useSnackQuery } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/apiHooks'; +import { VaccineRepositotyForms } from '../../types'; const getVaccineReporting = params => { const apiParams = params.campaignStatus @@ -23,7 +23,7 @@ export const tableDefaults = { type Response = { limit: number; count: number; - results: VaccineReporting[]; + results: VaccineRepositotyForms[]; has_previous: boolean; has_next: boolean; page: number; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useVaccineRepositoryColumns.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx similarity index 80% rename from plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useVaccineRepositoryColumns.tsx rename to plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx index ec9cb7a651..6f590de51b 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useVaccineRepositoryColumns.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/hooks/useVaccineRepositoryColumns.tsx @@ -1,10 +1,10 @@ -import React, { useMemo } from 'react'; import { Column, useSafeIntl } from 'bluesquare-components'; -import { DateCell } from '../../../../../../../../hat/assets/js/apps/Iaso/components/Cells/DateTimeCell'; -import { DocumentsCells } from '../components/DocumentsCell'; -import { FormADocumentsCells } from '../components/FormADocumentCells'; -import { VrfDocumentsCells } from '../components/VrfDocumentsCell'; -import MESSAGES from '../messages'; +import React, { useMemo } from 'react'; +import { DateCell } from '../../../../../../../../../hat/assets/js/apps/Iaso/components/Cells/DateTimeCell'; +import { DocumentsCells } from '../../components/DocumentsCell'; +import { FormADocumentsCells } from '../../components/FormADocumentCells'; +import { VrfDocumentsCells } from '../../components/VrfDocumentsCell'; +import MESSAGES from '../../messages'; export const useVaccineRepositoryColumns = (): Column[] => { const { formatMessage } = useSafeIntl(); @@ -49,18 +49,21 @@ export const useVaccineRepositoryColumns = (): Column[] => { accessor: 'vrf_data', Cell: VrfDocumentsCells, width: 30, + sortable: false, }, { Header: 'Pre Alert', accessor: 'pre_alert_data', Cell: DocumentsCells, width: 30, + sortable: false, }, { Header: 'Form A', accessor: 'form_a_data', Cell: FormADocumentsCells, width: 20, + sortable: false, }, ], [formatMessage], diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx new file mode 100644 index 0000000000..234db6184b --- /dev/null +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/forms/index.tsx @@ -0,0 +1,63 @@ +import { Column } from 'bluesquare-components'; +import React, { FunctionComponent } from 'react'; +import { useLocation } from 'react-router-dom'; +import { TableWithDeepLink } from '../../../../../../../../hat/assets/js/apps/Iaso/components/tables/TableWithDeepLink'; +import { getNonPrefixedParams } from '../../../../../../../../hat/assets/js/apps/Iaso/routing/common'; +import { baseUrls } from '../../../../constants/urls'; +import { VaccineRepositoryParams } from '../types'; +import { Filters } from './Filters'; +import { + tableDefaults, + useGetVaccineReporting, +} from './hooks/useGetVaccineReporting'; +import { useVaccineRepositoryColumns } from './hooks/useVaccineRepositoryColumns'; + +type Props = { + params: VaccineRepositoryParams; +}; + +const baseUrl = baseUrls.vaccineRepository; +const embeddedVaccineRepositoryUrl = baseUrls.embeddedVaccineRepository; + +const NOPADDING_CELLS_IDS = ['vrf_data', 'pre_alert_data', 'form_a_data']; + +const getCellProps = cell => { + const { id } = cell.column as Column; + return { + style: { + padding: NOPADDING_CELLS_IDS.includes(id as string) ? 0 : undefined, + verticalAlign: 'top', + }, + }; +}; + +export const Forms: FunctionComponent = ({ params }) => { + const location = useLocation(); + const formsParams = getNonPrefixedParams('report', params, ['accountId']); + const isEmbedded = location.pathname.includes(embeddedVaccineRepositoryUrl); + const redirectUrl = isEmbedded ? embeddedVaccineRepositoryUrl : baseUrl; + const { data, isFetching } = useGetVaccineReporting(formsParams); + const columns = useVaccineRepositoryColumns(); + + return ( + <> + + + + ); +}; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetFileTypes.ts b/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetFileTypes.ts index 635531cf21..cd9a472fc6 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetFileTypes.ts +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/hooks/useGetFileTypes.ts @@ -28,3 +28,24 @@ export const useGetFileTypes = (): DropdownOptions[] => { [formatMessage], ); }; + +export const useGetReportFileTypes = (): DropdownOptions[] => { + const { formatMessage } = useSafeIntl(); + return useMemo( + () => [ + { + value: 'INCIDENT,DESTRUCTION', + label: formatMessage(MESSAGES.all), + }, + { + value: 'INCIDENT', + label: formatMessage(MESSAGES.incidentReports), + }, + { + value: 'DESTRUCTION', + label: formatMessage(MESSAGES.destructionReports), + }, + ], + [formatMessage], + ); +}; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/messages.ts b/plugins/polio/js/src/domains/VaccineModule/Repository/messages.ts index 93b24ae1a9..bbdac9d75e 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/messages.ts +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/messages.ts @@ -61,10 +61,26 @@ const MESSAGES = defineMessages({ id: 'iaso.polio.label.vrfTypeMissing', defaultMessage: 'Missing', }, + incidentReports: { + id: 'iaso.polio.label.incidentReports', + defaultMessage: 'Incident reports', + }, + destructionReports: { + id: 'iaso.polio.label.destructionReports', + defaultMessage: 'Destruction reports', + }, vaccine: { id: 'iaso.polio.vaccine', defaultMessage: 'Vaccine', }, + forms: { + id: 'iaso.forms.title', + defaultMessage: 'Forms', + }, + reports: { + id: 'iaso.polio.reports', + defaultMessage: 'Reports', + }, }); export default MESSAGES; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/reports/Filters.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/Filters.tsx new file mode 100644 index 0000000000..d943151273 --- /dev/null +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/Filters.tsx @@ -0,0 +1,159 @@ +import FiltersIcon from '@mui/icons-material/FilterList'; +import { Box, Button, Grid } from '@mui/material'; +import { useRedirectToReplace } from 'bluesquare-components'; +import React, { + FunctionComponent, + useCallback, + useEffect, + useState, +} from 'react'; +import { FormattedMessage } from 'react-intl'; +import InputComponent from '../../../../../../../../hat/assets/js/apps/Iaso/components/forms/InputComponent'; +import { useGetGroupDropdown } from '../../../../../../../../hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups'; +import MESSAGES from '../../../../constants/messages'; +import { useGetCountries } from '../../../../hooks/useGetCountries'; + +import { appId } from '../../../../constants/app'; +import { defaultVaccineOptions } from '../../SupplyChain/constants'; +import { useGetReportFileTypes } from '../hooks/useGetFileTypes'; +import { VaccineRepositoryParams } from '../types'; + +type Props = { + params: VaccineRepositoryParams; + disableDates?: boolean; + isEmbedded?: boolean; + redirectUrl: string; +}; + +export const Filters: FunctionComponent = ({ params, redirectUrl }) => { + const redirectToReplace = useRedirectToReplace(); + + const [filtersUpdated, setFiltersUpdated] = useState(false); + const [countries, setCountries] = useState(params.reportCountries); + const [fileType, setFileType] = useState(params.reportFileType || 'INCIDENT,DESTRUCTION'); + const [vaccineName, setVaccineName] = useState(params.reportVaccineName); + const [countryBlocks, setCountryBlocks] = useState( + params.reportCountryBlock, + ); + + const handleSearch = useCallback(() => { + if (filtersUpdated) { + setFiltersUpdated(false); + const urlParams = { + ...params, + reportCountries: countries, + page: undefined, + reportCountryBlock: countryBlocks, + reportFileType: fileType, + reportVaccineName: vaccineName, + }; + redirectToReplace(redirectUrl, urlParams); + } + }, [ + filtersUpdated, + params, + countries, + countryBlocks, + fileType, + redirectToReplace, + redirectUrl, + vaccineName, + ]); + const { data, isFetching: isFetchingCountries } = useGetCountries(); + // Pass the appId to have it works in the embedded vaccine stock where the user is not connected + const { data: groupedOrgUnits, isFetching: isFetchingGroupedOrgUnits } = + useGetGroupDropdown({ blockOfCountries: 'True', appId }); + + const countriesList = (data && data.orgUnits) || []; + + const fileTypes = useGetReportFileTypes(); + useEffect(() => { + setFiltersUpdated(true); + }, [countries, countryBlocks, fileType, vaccineName]); + + useEffect(() => { + setFiltersUpdated(false); + }, []); + + return ( + <> + + + { + setCountryBlocks(value); + }} + value={countryBlocks} + type="select" + options={groupedOrgUnits} + label={MESSAGES.countryBlock} + /> + + + { + setCountries(value); + }} + value={countries} + type="select" + options={countriesList.map(c => ({ + label: c.name, + value: c.id, + }))} + label={MESSAGES.country} + /> + + + { + setFileType(value); + }} + value={fileType} + type="select" + options={fileTypes} + label={MESSAGES.fileType} + /> + + + { + setVaccineName(value); + }} + value={vaccineName} + type="select" + options={defaultVaccineOptions} + label={MESSAGES.vaccine} + /> + + + + + + + + + + ); +}; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/reports/hooks/useGetVaccineRepositoryReports.ts b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/hooks/useGetVaccineRepositoryReports.ts new file mode 100644 index 0000000000..b31c09a73d --- /dev/null +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/hooks/useGetVaccineRepositoryReports.ts @@ -0,0 +1,54 @@ +import { UseQueryResult } from 'react-query'; +import { useApiParams } from '../../../../../../../../../hat/assets/js/apps/Iaso/hooks/useApiParams'; +import { getRequest } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/Api'; +import { useSnackQuery } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/apiHooks'; +import { ReportParams, VaccineRepositotyForms } from '../../types'; + +export const tableDefaults = { + order: 'country_name', + limit: 10, + page: 1, +}; + +const getVaccineRepositoryReports = params => { + const queryString = new URLSearchParams(params).toString(); + return getRequest(`/api/polio/vaccine/repository_reports/?${queryString}`); +}; + +type Response = { + limit: number; + count: number; + results: VaccineRepositotyForms[]; + has_previous: boolean; + has_next: boolean; + page: number; + pages: number; +}; + +export const useGetVaccineRepositoryReports = ( + params: ReportParams, + defaults = tableDefaults, +): UseQueryResult => { + const safeParams: Record = useApiParams( + { + countries: params.reportCountries, + country_block: params.reportCountryBlock, + file_type: params.reportFileType, + vaccine_name: params.reportVaccineName, + page_size: params.reportPageSize || tableDefaults.limit, + order: params.reportOrder || tableDefaults.order, + page: params.reportPage || `${tableDefaults.page}`, + }, + defaults, + ); + + return useSnackQuery({ + queryKey: ['vaccineRepositoryReports', safeParams], + queryFn: () => getVaccineRepositoryReports(safeParams), + options: { + staleTime: 60000, + cacheTime: 60000, + keepPreviousData: true, + }, + }); +}; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/reports/hooks/useVaccineRepositoryReportsColumns.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/hooks/useVaccineRepositoryReportsColumns.tsx new file mode 100644 index 0000000000..26029fb1bb --- /dev/null +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/hooks/useVaccineRepositoryReportsColumns.tsx @@ -0,0 +1,37 @@ +import { Column, useSafeIntl } from 'bluesquare-components'; +import { useMemo } from 'react'; +import { DocumentsCells } from '../../components/DocumentsCell'; +import MESSAGES from '../../messages'; + +export const useVaccineRepositoryReportsColumns = (): Column[] => { + const { formatMessage } = useSafeIntl(); + return useMemo( + () => [ + { + Header: formatMessage(MESSAGES.country), + id: 'country__name', + accessor: 'country_name', + align: 'left', + }, + { + Header: formatMessage(MESSAGES.vaccine), + id: 'vaccine', + accessor: 'vaccine', + width: 20, + }, + { + Header: formatMessage(MESSAGES.incidentReports), + accessor: 'incident_report_data', + Cell: DocumentsCells, + sortable: false, + }, + { + Header: formatMessage(MESSAGES.destructionReports), + accessor: 'destruction_report_data', + Cell: DocumentsCells, + sortable: false, + }, + ], + [formatMessage], + ); +}; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/reports/index.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/index.tsx new file mode 100644 index 0000000000..5987f8dd7f --- /dev/null +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/reports/index.tsx @@ -0,0 +1,66 @@ +import { Column } from 'bluesquare-components'; +import React, { FunctionComponent, useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; +import { TableWithDeepLink } from '../../../../../../../../hat/assets/js/apps/Iaso/components/tables/TableWithDeepLink'; +import { getPrefixedParams } from '../../../../../../../../hat/assets/js/apps/Iaso/routing/common'; +import { baseUrls } from '../../../../constants/urls'; +import { VaccineRepositoryParams } from '../types'; +import { Filters } from './Filters'; +import { + tableDefaults, + useGetVaccineRepositoryReports, +} from './hooks/useGetVaccineRepositoryReports'; +import { useVaccineRepositoryReportsColumns } from './hooks/useVaccineRepositoryReportsColumns'; + +type Props = { + params: VaccineRepositoryParams; +}; + +const baseUrl = baseUrls.vaccineRepository; +const embeddedVaccineRepositoryUrl = baseUrls.embeddedVaccineRepository; + +const NOPADDING_CELLS_IDS = ['incident_report_data', 'destruction_report_data']; + +const getCellProps = cell => { + const { id } = cell.column as Column; + return { + style: { + padding: NOPADDING_CELLS_IDS.includes(id as string) ? 0 : undefined, + verticalAlign: 'top', + }, + }; +}; +export const Reports: FunctionComponent = ({ params }) => { + const reportParams = useMemo( + () => getPrefixedParams('report', params), + [params], + ); + const location = useLocation(); + const isEmbedded = location.pathname.includes(embeddedVaccineRepositoryUrl); + const redirectUrl = isEmbedded ? embeddedVaccineRepositoryUrl : baseUrl; + + const { data, isFetching } = useGetVaccineRepositoryReports(reportParams); + const columns = useVaccineRepositoryReportsColumns(); + return ( + <> + + + + ); +}; diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/types.ts b/plugins/polio/js/src/domains/VaccineModule/Repository/types.ts index 75b53b10ef..9ba0db0773 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/types.ts +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/types.ts @@ -1,7 +1,17 @@ import { PaginationParams } from '../../../../../../../hat/assets/js/apps/Iaso/types/general'; import { Vaccine } from '../../../constants/types'; -export type VaccineRepositoryParams = PaginationParams & { +export type ReportParams = { + reportCountries?: string; + reportCountryBlock?: string; + reportFileType?: string; + reportVaccineName?: string; + reportPageSize?: string; + reportOrder?: string; + reportPage?: string; +}; + +export type FormsParams = { countries?: string; campaignType?: string; country_block?: string; @@ -10,12 +20,19 @@ export type VaccineRepositoryParams = PaginationParams & { vaccine_name?: Vaccine; }; +export type VaccineRepositoryParams = PaginationParams & + ReportParams & + FormsParams & { + tab?: string; + accountId?: string; + }; + export type DocumentData = { date?: string; file?: string; }; -export type VaccineReporting = { +export type VaccineRepositotyForms = { country_name: string; campaign_obr_name: string; rounds_count: string; @@ -26,3 +43,10 @@ export type VaccineReporting = { incident_reports: DocumentData[]; destruction_reports: DocumentData[]; }; + +export type VaccineRepositoryReports = { + country_name: string; + vaccine: string; + incident_report_data: DocumentData[]; + destruction_report_data: DocumentData[]; +}; diff --git a/plugins/polio/tests/test_vaccine_repository.py b/plugins/polio/tests/test_vaccine_repository_forms.py similarity index 99% rename from plugins/polio/tests/test_vaccine_repository.py rename to plugins/polio/tests/test_vaccine_repository_forms.py index e36ccddf06..9f09655a39 100644 --- a/plugins/polio/tests/test_vaccine_repository.py +++ b/plugins/polio/tests/test_vaccine_repository_forms.py @@ -12,7 +12,7 @@ BASE_URL = "/api/polio/vaccine/repository/" -class VaccineRepositoryAPITestCase(APITestCase, PolioTestCaseMixin): +class VaccineRepositoryFormsAPITestCase(APITestCase, PolioTestCaseMixin): @classmethod def setUp(cls): cls.data_source = m.DataSource.objects.create(name="Default source") diff --git a/plugins/polio/tests/test_vaccine_repository_reports.py b/plugins/polio/tests/test_vaccine_repository_reports.py new file mode 100644 index 0000000000..342e711b94 --- /dev/null +++ b/plugins/polio/tests/test_vaccine_repository_reports.py @@ -0,0 +1,178 @@ +import datetime + +from django.contrib.auth.models import AnonymousUser +from django.utils.timezone import now + +import hat.menupermissions.models as permissions +from iaso import models as m +from iaso.test import APITestCase +from plugins.polio import models as pm +from plugins.polio.tests.api.test import PolioTestCaseMixin + +REPORTS_URL = "/api/polio/vaccine/repository_reports/" + + +class VaccineRepositoryReportsAPITestCase(APITestCase, PolioTestCaseMixin): + @classmethod + def setUp(cls): + cls.data_source = m.DataSource.objects.create(name="Default source") + cls.source_version_1 = m.SourceVersion.objects.create(data_source=cls.data_source, number=1) + cls.account = m.Account.objects.create(name="polio", default_version=cls.source_version_1) + cls.now = now() + + cls.org_unit_type_country = m.OrgUnitType.objects.create(name="Country") + cls.org_unit_type_district = m.OrgUnitType.objects.create(name="District") + + cls.campaign, cls.campaign_round_1, _, _, cls.testland, _district = cls.create_campaign( + obr_name="Test Campaign", + account=cls.account, + source_version=cls.source_version_1, + country_ou_type=cls.org_unit_type_country, + country_name="Testland", + district_ou_type=cls.org_unit_type_district, + ) + cls.campaign_no_vrf, cls.campaign_no_vrf_round_1, _, _, _, _ = cls.create_campaign( + obr_name="No VRF", + account=cls.account, + source_version=cls.source_version_1, + country_ou_type=cls.org_unit_type_country, + country_name="Testland", + district_ou_type=cls.org_unit_type_district, + ) + # Has same country as cls.campaign, but no vrf. Should not appear in any API payload + cls.campaign_no_vrf.country = cls.testland + cls.campaign_no_vrf.save() + + cls.zambia = m.OrgUnit.objects.create( + org_unit_type=cls.org_unit_type_country, + version=cls.source_version_1, + name="Zambia", + validation_status=m.OrgUnit.VALIDATION_VALID, + source_ref="ZambiaRef", + ) + + # Create campaign type + cls.polio_type, _ = pm.CampaignType.objects.get_or_create(name="Polio") + + cls.campaign.campaign_types.add(cls.polio_type) + + # Create vaccine stock and reports + cls.vaccine_stock = pm.VaccineStock.objects.create( + account=cls.account, country=cls.testland, vaccine=pm.VACCINES[0][0] + ) + + cls.incident_report = pm.IncidentReport.objects.create( + vaccine_stock=cls.vaccine_stock, + date_of_incident_report=cls.now - datetime.timedelta(days=5), + incident_report_received_by_rrt=cls.now, + usable_vials=100, + unusable_vials=0, + stock_correction=pm.IncidentReport.StockCorrectionChoices.VVM_REACHED_DISCARD_POINT, + title="Test incident", + comment="Test incident", + ) + + cls.destruction_report = pm.DestructionReport.objects.create( + vaccine_stock=cls.vaccine_stock, + rrt_destruction_report_reception_date=cls.now, + destruction_report_date=cls.now - datetime.timedelta(days=2), + unusable_vials_destroyed=50, + action="EXPIRED", + comment="Test destruction", + ) + + # Create users + cls.anon = AnonymousUser() + cls.user = cls.create_user_with_profile(username="user", account=cls.account) + + def test_anonymous_user_can_see_reports(self): + """Test that anonymous users can access the reports endpoint""" + self.client.force_authenticate(user=self.anon) + response = self.client.get(REPORTS_URL) + self.assertEqual(response.status_code, 200) + + def test_reports_list_response_structure(self): + """Test the structure of the reports list response""" + self.client.force_authenticate(user=self.user) + response = self.client.get(REPORTS_URL) + data = response.json()["results"] + + # Check result fields + result = data[0] + self.assertIn("country_name", result) + self.assertIn("country_id", result) + self.assertIn("vaccine", result) + self.assertIn("incident_report_data", result) + self.assertIn("destruction_report_data", result) + + def test_reports_filtering(self): + """Test filtering functionality of reports endpoint""" + self.client.force_authenticate(user=self.user) + + # Test filtering by country + response = self.client.get(f"{REPORTS_URL}?countries={self.testland.id}") + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["country_name"], "Testland") + + # Test filtering by vaccine name + response = self.client.get(f"{REPORTS_URL}?vaccine_name={pm.VACCINES[0][0]}") + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["vaccine"], pm.VACCINES[0][0]) + + # Test filtering by file type + response = self.client.get(f"{REPORTS_URL}?file_type=INCIDENT") + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertTrue(len(data[0]["incident_report_data"]) > 0) + + response = self.client.get(f"{REPORTS_URL}?file_type=DESTRUCTION") + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertTrue(len(data[0]["destruction_report_data"]) > 0) + + # Test filtering by country block + country_group = self.testland.groups.first() + if country_group: + response = self.client.get(f"{REPORTS_URL}?country_block={country_group.id}") + data = response.json()["results"] + self.assertEqual(len(data), 1) + self.assertEqual(data[0]["country_name"], "Testland") + + def test_reports_ordering(self): + """Test ordering functionality of reports endpoint""" + # Create another vaccine stock in Zambia + zambia_stock = pm.VaccineStock.objects.create( + account=self.account, country=self.zambia, vaccine=pm.VACCINES[1][0] + ) + pm.IncidentReport.objects.create( + vaccine_stock=zambia_stock, + date_of_incident_report=self.now, + incident_report_received_by_rrt=self.now, + usable_vials=100, + unusable_vials=0, + stock_correction=pm.IncidentReport.StockCorrectionChoices.VVM_REACHED_DISCARD_POINT, + title="Zambia incident", + comment="Zambia incident", + ) + + self.client.force_authenticate(user=self.user) + + # Test ordering by country name + response = self.client.get(f"{REPORTS_URL}?order=country__name") + data = response.json()["results"] + self.assertEqual(data[0]["country_name"], "Testland") + self.assertEqual(data[1]["country_name"], "Zambia") + + # Test reverse ordering by country name + response = self.client.get(f"{REPORTS_URL}?order=-country__name") + data = response.json()["results"] + self.assertEqual(data[0]["country_name"], "Zambia") + self.assertEqual(data[1]["country_name"], "Testland") + + # Test ordering by vaccine + response = self.client.get(f"{REPORTS_URL}?order=vaccine") + data = response.json()["results"] + self.assertEqual(data[0]["vaccine"], pm.VACCINES[0][0]) + self.assertEqual(data[1]["vaccine"], pm.VACCINES[1][0])