diff --git a/README.md b/README.md index 85af9d75..5674dc11 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,21 @@ docker compose run iaso manage set_up_burkina_faso_account 6. Using the credentials you just received, you should now be able to log in and create a first scenario. +## Configuration + +The planning page requires a configuration entry in the Iaso datastore to know which org unit types represent the country level and the intervention level. Without this configuration (or if it contains invalid values), the planning sidebar will only show the "National" display level. + +To set it up, go to the Django admin for your account and create a datastore entry with the key `snt_malaria_config` and the following JSON data: + +```json +{ + "country_org_unit_type_id": , + "intervention_org_unit_type_id": +} +``` + +Replace `` with the actual org unit type IDs for your country. Both values must be numbers. + ## OpenHEXA import This section describes how to fetch real data layers from OpenHEXA. diff --git a/js/src/components/FitBounds.tsx b/js/src/components/FitBounds.tsx new file mode 100644 index 00000000..9b6e9f91 --- /dev/null +++ b/js/src/components/FitBounds.tsx @@ -0,0 +1,24 @@ +import { FC, useEffect } from 'react'; +import L from 'leaflet'; +import { useMap } from 'react-leaflet'; + +/** + * Fits the map to the given bounds whenever they change. + * Place this inside a `` — react-leaflet treats + * `bounds` on MapContainer as an immutable prop, so this component + * is needed to react to bound changes (e.g. when filtered org units change). + */ +export const FitBounds: FC<{ + bounds: L.LatLngBounds | undefined; + boundsOptions?: L.FitBoundsOptions; +}> = ({ bounds, boundsOptions = {} }) => { + const map = useMap(); + + useEffect(() => { + if (bounds && map) { + map.fitBounds(bounds, boundsOptions); + } + }, [bounds, boundsOptions, map]); + + return null; +}; diff --git a/js/src/components/InvalidateOnResize.tsx b/js/src/components/InvalidateOnResize.tsx new file mode 100644 index 00000000..3969c3cd --- /dev/null +++ b/js/src/components/InvalidateOnResize.tsx @@ -0,0 +1,22 @@ +import { FC, useEffect } from 'react'; +import { useMap } from 'react-leaflet'; + +/** + * Calls `map.invalidateSize()` whenever the map container is resized. + * Place this inside a `` to handle dynamic layout changes + * (e.g. sidebar open/close) that Leaflet can't detect on its own. + */ +export const InvalidateOnResize: FC = () => { + const map = useMap(); + + useEffect(() => { + const container = map.getContainer(); + const observer = new ResizeObserver(() => { + map.invalidateSize(); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [map]); + + return null; +}; diff --git a/js/src/components/Map.tsx b/js/src/components/Map.tsx index 10f81855..c891e45c 100644 --- a/js/src/components/Map.tsx +++ b/js/src/components/Map.tsx @@ -12,6 +12,8 @@ import tiles from 'Iaso/constants/mapTiles'; import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; import { noOp } from 'Iaso/utils'; import { Bounds } from 'Iaso/utils/map/mapUtils'; +import { FitBounds } from './FitBounds'; +import { InvalidateOnResize } from './InvalidateOnResize'; import { mapTheme } from '../constants/map-theme'; import { MapLegend } from '../domains/planning/components/MapLegend'; import { @@ -85,6 +87,8 @@ export const Map: FC = ({ zoomSnap={defaultZoomSnap} zoomDelta={defaultZoomDelta} > + + {orgUnits?.map(orgUnit => { const orgUnitMapMisc = getOrgUnitMapMisc(orgUnit.id); diff --git a/js/src/constants/translations/en.json b/js/src/constants/translations/en.json index b79795b3..786e3b57 100644 --- a/js/src/constants/translations/en.json +++ b/js/src/constants/translations/en.json @@ -10,6 +10,7 @@ "iaso.snt_malaria.label.interventionList.tableNoContent": "Intervention and their districts will appear here.", "iaso.snt_malaria.label.interventionPlanTitle": "Intervention plan", "iaso.snt_malaria.label.layers": "Layers", + "iaso.snt_malaria.label.national": "National", "iaso.snt_malaria.label.noInterventionPlanAvailable": "No intervention plan available", "iaso.snt_malaria.label.none": "None", "iaso.snt_malaria.label.ok": "OK", @@ -139,9 +140,12 @@ "iaso.snt_malaria.budgetAssumptions.path": "Budgeting methodology based on tools and documentation developed by PATH.", "iaso.snt_malaria.budgetAssumptions.minValue": "Value must be at least {min}", "iaso.snt_malaria.budgetAssumptions.maxValue": "Value must be at most {max}", - "iaso.snt_malaria.label.showLegend": "Show Legend", - "iaso.snt_malaria.label.hideLegend": "Hide Legend", + "iaso.snt_malaria.label.allDisplayLevels": "All", + "iaso.snt_malaria.label.allOrgUnits": "All", + "iaso.snt_malaria.label.allParentOrgUnits": "All", + "iaso.snt_malaria.label.displayLevel": "Display level", "iaso.snt_malaria.label.duplicate": "Duplicate", + "iaso.snt_malaria.label.hideLegend": "Hide Legend", "iaso.snt_malaria.scenarioRule.apply": "Apply rules", "iaso.snt_malaria.scenarioRule.create": "Create rule", "iaso.snt_malaria.scenarioRule.edit": "Edit rule", @@ -157,5 +161,10 @@ "iaso.snt_malaria.scenarioRule.operator": "Operator", "iaso.snt_malaria.scenarioRule.value": "Value", "iaso.snt_malaria.scenarioRule.selectionCriteria": "Selection Criteria", - "iaso.snt_malaria.scenarioRule.interventionProperties": "Interventions" + "iaso.snt_malaria.scenarioRule.interventionProperties": "Interventions", + "iaso.snt_malaria.label.hideSidebar": "Hide sidebar", + "iaso.snt_malaria.label.parentOrgUnit": "Parent", + "iaso.snt_malaria.label.showLegend": "Show Legend", + "iaso.snt_malaria.label.showSidebar": "Show sidebar", + "iaso.snt_malaria.label.sidebarTitle": "Display options" } diff --git a/js/src/constants/translations/fr.json b/js/src/constants/translations/fr.json index db759b2d..194cf525 100644 --- a/js/src/constants/translations/fr.json +++ b/js/src/constants/translations/fr.json @@ -9,6 +9,7 @@ "iaso.snt_malaria.label.interventionList.tableNoContent": "Les interventions et leurs districts apparaîtront ici", "iaso.snt_malaria.label.interventionPlanTitle": "Plan d'intervention", "iaso.snt_malaria.label.layers": "Couches", + "iaso.snt_malaria.label.national": "National", "iaso.snt_malaria.label.noInterventionPlanAvailable": "Aucun plan d'intervention disponible", "iaso.snt_malaria.label.none": "Aucun", "iaso.snt_malaria.label.ok": "OK", @@ -139,9 +140,12 @@ "iaso.snt_malaria.budgetAssumptions.path": "Méthodologie de budgétisation basée sur les outils et la documentation développés par PATH.", "iaso.snt_malaria.budgetAssumptions.minValue": "La valeur doit être au minimum {min}", "iaso.snt_malaria.budgetAssumptions.maxValue": "La valeur doit être au maximum {max}", - "iaso.snt_malaria.label.showLegend": "Afficher la légende", - "iaso.snt_malaria.label.hideLegend": "Masquer la légende", + "iaso.snt_malaria.label.allDisplayLevels": "Tous", + "iaso.snt_malaria.label.allOrgUnits": "Tous", + "iaso.snt_malaria.label.allParentOrgUnits": "Tous", + "iaso.snt_malaria.label.displayLevel": "Niveau d'affichage", "iaso.snt_malaria.label.duplicate": "Dupliquer", + "iaso.snt_malaria.label.hideLegend": "Masquer la légende", "iaso.snt_malaria.scenarioRule.apply": "Appliquer les règles", "iaso.snt_malaria.scenarioRule.create": "Créer une règle", "iaso.snt_malaria.scenarioRule.edit": "Modifier la règle", @@ -157,5 +161,10 @@ "iaso.snt_malaria.scenarioRule.operator": "Opérateur", "iaso.snt_malaria.scenarioRule.value": "Valeur", "iaso.snt_malaria.scenarioRule.selectionCriteria": "Critères de sélection", - "iaso.snt_malaria.scenarioRule.interventionProperties": "Interventions" + "iaso.snt_malaria.scenarioRule.interventionProperties": "Interventions", + "iaso.snt_malaria.label.hideSidebar": "Masquer le panneau latéral", + "iaso.snt_malaria.label.parentOrgUnit": "Parent", + "iaso.snt_malaria.label.showLegend": "Afficher la légende", + "iaso.snt_malaria.label.showSidebar": "Afficher le panneau latéral", + "iaso.snt_malaria.label.sidebarTitle": "Options d'affichage" } diff --git a/js/src/constants/urls.ts b/js/src/constants/urls.ts index a296b0cc..93d0ab38 100644 --- a/js/src/constants/urls.ts +++ b/js/src/constants/urls.ts @@ -9,7 +9,7 @@ import { paginationPathParams } from 'Iaso/routing/common'; export const RouteConfigs: Record = { planning: { url: 'snt_malaria/planning', - params: ['scenarioId'], + params: ['scenarioId', 'displayOrgUnitTypeId', 'displayOrgUnitId'], }, planningV2: { url: 'snt_malaria/planning-v2', diff --git a/js/src/domains/messages.ts b/js/src/domains/messages.ts index 7f0d6e02..7b33001f 100644 --- a/js/src/domains/messages.ts +++ b/js/src/domains/messages.ts @@ -393,60 +393,60 @@ export const MESSAGES = defineMessages({ budgetAssumptionsDescription_itn_campaign: { id: 'iaso.snt_malaria.budgetAssumptions.description.itn_campaign', defaultMessage: `To estimate the number of insecticide-treated nets (ITNs) needed for campaign delivery in targeted areas:

- The target population (default: total pop) of the area is multiplied by the target intervention coverage - (default: 100%) to estimate the target population for the campaign and then divided by the number of people - assumed to use 1 net (default: 1.8). A buffer (default: 10%) is applied to account for wastage and contingency. + The target population (default: total pop) of the area is multiplied by the target intervention coverage + (default: 100%) to estimate the target population for the campaign and then divided by the number of people + assumed to use 1 net (default: 1.8). A buffer (default: 10%) is applied to account for wastage and contingency. The number of bales is calculated by dividing the number of nets by 50 (assuming 50 nets per bale).`, }, budgetAssumptionsDescription_itn_routine: { id: 'iaso.snt_malaria.budgetAssumptions.description.itn_routine', - defaultMessage: `To estimate the number of nets needed for routine delivery channels often through ANC and EPI - services, the target population (default: total under 5 and pregnant women population) of an area is multiplied + defaultMessage: `To estimate the number of nets needed for routine delivery channels often through ANC and EPI + services, the target population (default: total under 5 and pregnant women population) of an area is multiplied by the expected routine distribution coverage (default: 30%) and a procurement buffer (default: 10%).`, }, budgetAssumptionsDescription_iptp: { id: 'iaso.snt_malaria.budgetAssumptions.description.iptp', - defaultMessage: `To estimate the amount of SP (blister packs of 3 pills) to procure for IPTp - we take the target population (default: pregnant women) of an area, - multiply by the expected coverage at ANC attendence (default: 80%) for the scheduled number of touchpoints + defaultMessage: `To estimate the amount of SP (blister packs of 3 pills) to procure for IPTp + we take the target population (default: pregnant women) of an area, + multiply by the expected coverage at ANC attendence (default: 80%) for the scheduled number of touchpoints per woman (default: 3) and multiply this by a procurement buffer (default: 10%).`, }, budgetAssumptionsDescription_smc: { id: 'iaso.snt_malaria.budgetAssumptions.description.smc', - defaultMessage: `To estimate the number of SP+AQ co-blistered packets required for Seasonal Malaria Chemoprevention (SMC), - we use the following methodology with the default assumptions included here for clarity. - We first assume each packet contains one full course for a single cycle (1 tablet of SP and 3 tablets of AQ). - SMC is at default delivered over 4 monthly cycles and targets two age groups: - children aged 3 to <12 months and children aged >12 to 59 months. - We include the distribution of age-groups as the procurement costs of the co-blistered packets + defaultMessage: `To estimate the number of SP+AQ co-blistered packets required for Seasonal Malaria Chemoprevention (SMC), + we use the following methodology with the default assumptions included here for clarity. + We first assume each packet contains one full course for a single cycle (1 tablet of SP and 3 tablets of AQ). + SMC is at default delivered over 4 monthly cycles and targets two age groups: + children aged 3 to <12 months and children aged >12 to 59 months. + We include the distribution of age-groups as the procurement costs of the co-blistered packets for these different age groups vary as a result of the age-based dosing requirements for SMC drugs. - We first estimate the target population by applying fixed proportions to the total number of children under 5 years of age. - Coverage of the target population is assumed to be 100%, unless otherwise specified, + We first estimate the target population by applying fixed proportions to the total number of children under 5 years of age. + Coverage of the target population is assumed to be 100%, unless otherwise specified, and is applied before the buffer is calculated. A 10% buffer is then included to account for re-dosing, wastage, and the treatment of children from outside the catchment area.`, }, budgetAssumptionsDescription_pmc: { id: 'iaso.snt_malaria.budgetAssumptions.description.pmc', - defaultMessage: `To estimate the quantity of sulfadoxine-pyrimethamine (SP) required for Perennial Malaria Chemoprevention (PMC), - we assume delivery is integrated into routine Expanded Programme on Immunization (EPI). + defaultMessage: `To estimate the quantity of sulfadoxine-pyrimethamine (SP) required for Perennial Malaria Chemoprevention (PMC), + we assume delivery is integrated into routine Expanded Programme on Immunization (EPI). Each eligible child receives SP at four routine immunization touchpoints per year, with age-specific dosing:
  • Children aged 0-1 years receive 1 tablet of SP per contact.
  • Children aged 1-2 years receive 2 tablets of SP per contact.
- To account for underdosing due to low weight, which affects approximately 25% of children in each age group, - a scaling factor of 0.75 is applied to both age groups. + To account for underdosing due to low weight, which affects approximately 25% of children in each age group, + a scaling factor of 0.75 is applied to both age groups. This factor reflects the average reduction in tablets required due to dose adjustment (e.g., half tablets for underweight infants). An 85% coverage rate is assumed and a 10% procurement buffer is then included to cover wastage, re-dosing, and stockouts.`, }, budgetAssumptionsDescription_vacc: { id: 'iaso.snt_malaria.budgetAssumptions.description.vacc', - defaultMessage: `To estimate the number of malaria vaccine doses required, at default, - we assume each eligible child will receive a 4-dose schedule. - Assuming the first three doses are delivered monthly and start around 5 months of age and the 4th dose is delivered ~12 - 15 months following the 3rd dose. - The vaccine is delivered through routine immunization contacts with an expected coverage of 84% among the target population. + defaultMessage: `To estimate the number of malaria vaccine doses required, at default, + we assume each eligible child will receive a 4-dose schedule. + Assuming the first three doses are delivered monthly and start around 5 months of age and the 4th dose is delivered ~12 - 15 months following the 3rd dose. + The vaccine is delivered through routine immunization contacts with an expected coverage of 84% among the target population. A 10% buffer is included to account for losses during transportation, storage, and administration.`, }, budgetAssumptionsPath: { @@ -522,4 +522,40 @@ export const MESSAGES = defineMessages({ id: 'iaso.snt_malaria.scenarioRule.interventionProperties', defaultMessage: 'Interventions', }, + showSidebar: { + id: 'iaso.snt_malaria.label.showSidebar', + defaultMessage: 'Show sidebar', + }, + hideSidebar: { + id: 'iaso.snt_malaria.label.hideSidebar', + defaultMessage: 'Hide sidebar', + }, + sidebarTitle: { + id: 'iaso.snt_malaria.label.sidebarTitle', + defaultMessage: 'Display options', + }, + parentOrgUnit: { + id: 'iaso.snt_malaria.label.parentOrgUnit', + defaultMessage: 'Parent', + }, + allParentOrgUnits: { + id: 'iaso.snt_malaria.label.allParentOrgUnits', + defaultMessage: 'All', + }, + displayLevel: { + id: 'iaso.snt_malaria.label.displayLevel', + defaultMessage: 'Display level', + }, + allDisplayLevels: { + id: 'iaso.snt_malaria.label.allDisplayLevels', + defaultMessage: 'All', + }, + allOrgUnits: { + id: 'iaso.snt_malaria.label.allOrgUnits', + defaultMessage: 'All', + }, + national: { + id: 'iaso.snt_malaria.label.national', + defaultMessage: 'National', + }, }); diff --git a/js/src/domains/planning/components/PlanningFiltersSidebar.tsx b/js/src/domains/planning/components/PlanningFiltersSidebar.tsx new file mode 100644 index 00000000..b5240bfe --- /dev/null +++ b/js/src/domains/planning/components/PlanningFiltersSidebar.tsx @@ -0,0 +1,221 @@ +import React, { FC, useMemo } from 'react'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import { + Box, + Card, + CardContent, + CardHeader, + CircularProgress, + FormControl, + MenuItem, + Select, + Typography, +} from '@mui/material'; +import { useSafeIntl } from 'bluesquare-components'; +import { useGetOrgUnitTypesDropdownOptions } from 'Iaso/domains/orgUnits/orgUnitTypes/hooks/useGetOrgUnitTypesDropdownOptions'; +import { SxStyles } from 'Iaso/types/general'; +import { MESSAGES } from '../../messages'; +import { useGetOrgUnitsByType } from '../hooks/useGetOrgUnits'; +import { useGetSNTConfig } from '../hooks/useGetSNTConfig'; + +const styles: SxStyles = { + sidebarCard: { + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + borderRadius: theme => theme.spacing(2), + boxShadow: 'none', + }, + sidebarCardContent: { + padding: 2, + height: '100%', + overflow: 'auto', + }, + formControl: { + width: '100%', + marginBottom: 2, + }, + select: theme => ({ + backgroundColor: 'white', + borderRadius: theme.spacing(1), + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'rgba(0, 0, 0, 0.23)', + }, + }), + label: { + marginBottom: 1, + fontWeight: 500, + fontSize: '0.875rem', + color: 'text.secondary', + }, + loadingContainer: { + display: 'flex', + justifyContent: 'center', + padding: 2, + }, +}; + +type Props = { + selectedOrgUnitTypeId: number | null; + selectedOrgUnitId: number | null; + onOrgUnitTypeChange: (orgUnitTypeId: number | null) => void; + onOrgUnitChange: (orgUnitId: number | null) => void; +}; + +export const PlanningFiltersSidebar: FC = ({ + selectedOrgUnitTypeId, + selectedOrgUnitId, + onOrgUnitTypeChange, + onOrgUnitChange, +}) => { + const { formatMessage } = useSafeIntl(); + + const { data: config, isLoading: isLoadingConfig } = useGetSNTConfig(); + const { data: allOUTypes, isLoading: isLoadingTypes } = + useGetOrgUnitTypesDropdownOptions(); + const { data: orgUnitsByType, isLoading: isLoadingOrgUnits } = + useGetOrgUnitsByType(selectedOrgUnitTypeId); + + // Validate presence and format of the SNT config + const filteringEnabled = useMemo(() => { + return ( + config && + typeof config.country_org_unit_type_id === 'number' && + typeof config.intervention_org_unit_type_id === 'number' + ); + }, [config]); + + // Filter org unit types: keep types whose depth is strictly between + // country and intervention (exclusive on both ends). + // + // Fallback: When config is not set, only show the "National" option and + // hide the OU dropdown. + const filteredOrgUnitTypes = useMemo(() => { + if (!allOUTypes || !filteringEnabled || !config) return []; + + const getDepth = (typeId: number) => { + const ouType = allOUTypes.find(t => Number(t.value) === typeId); + return (ouType?.original as { depth?: number | null })?.depth; + }; + const countryDepth = getDepth(config.country_org_unit_type_id); + const interventionDepth = getDepth( + config.intervention_org_unit_type_id, + ); + + if (countryDepth == null || interventionDepth == null) return []; + + return allOUTypes.filter(type => { + const depth = (type.original as { depth?: number | null }).depth; + return ( + depth != null && + depth > countryDepth && + depth < interventionDepth + ); + }); + }, [allOUTypes, config]); + + // Get the selected org unit type's label for the second dropdown label + const selectedOrgUnitTypeLabel = useMemo(() => { + if (!selectedOrgUnitTypeId || !filteredOrgUnitTypes) return null; + + return filteredOrgUnitTypes.find( + type => Number(type.value) === selectedOrgUnitTypeId, + )?.label; + }, [selectedOrgUnitTypeId, filteredOrgUnitTypes]); + + const handleTypeChange = event => { + const value = event.target.value; + onOrgUnitTypeChange(value === '' ? null : value); + }; + + const handleOrgUnitChange = event => { + const value = event.target.value; + onOrgUnitChange(value === '' ? null : value); + }; + + const isLoading = isLoadingConfig || isLoadingTypes; + + return ( + + + + {isLoading ? ( + + + + ) : ( + <> + + + {formatMessage(MESSAGES.displayLevel)} + + + + + + {selectedOrgUnitTypeId !== null && ( + + + {selectedOrgUnitTypeLabel ?? + formatMessage(MESSAGES.displayLevel)} + + + + + {isLoadingOrgUnits && ( + + + + )} + + )} + + )} + + + ); +}; diff --git a/js/src/domains/planning/components/ScenarioTopBar.tsx b/js/src/domains/planning/components/ScenarioTopBar.tsx index cfc0c2f7..4a4810e3 100644 --- a/js/src/domains/planning/components/ScenarioTopBar.tsx +++ b/js/src/domains/planning/components/ScenarioTopBar.tsx @@ -1,7 +1,14 @@ import React, { FC, useCallback } from 'react'; import LockIcon from '@mui/icons-material/Lock'; import LockOpenIcon from '@mui/icons-material/LockOpen'; -import { Box, Typography, Theme } from '@mui/material'; +import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined'; +import { + Box, + IconButton, + Theme, + Tooltip, + Typography, +} from '@mui/material'; import { useSafeIntl } from 'bluesquare-components'; import { useNavigate } from 'react-router-dom'; import ConfirmDialog from 'Iaso/components/dialogs/ConfirmDialogComponent'; @@ -34,13 +41,33 @@ const styles: SxStyles = { flexDirection: 'row', alignItems: 'center', }, + sidebarToggle: { + transition: 'all 0.2s ease-in-out', + }, + sidebarToggleActive: (theme: Theme) => ({ + transition: 'all 0.2s ease-in-out', + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + '&:hover': { + backgroundColor: theme.palette.primary.dark, + }, + }), + sidebarIcon: { + transition: 'transform 0.2s ease-in-out', + }, }; type Props = { scenario: Scenario; + isSidebarOpen: boolean; + onToggleSidebar: () => void; }; -export const ScenarioTopBar: FC = ({ scenario }) => { +export const ScenarioTopBar: FC = ({ + scenario, + isSidebarOpen, + onToggleSidebar, +}) => { const csvUrl = `${exportScenarioAPIPath}?id=${scenario.id}`; const navigate = useNavigate(); @@ -121,6 +148,27 @@ export const ScenarioTopBar: FC = ({ scenario }) => { /> )} + + + + + ); diff --git a/js/src/domains/planning/components/budgeting/Budgeting.tsx b/js/src/domains/planning/components/budgeting/Budgeting.tsx index d9051402..1fc87c7c 100644 --- a/js/src/domains/planning/components/budgeting/Budgeting.tsx +++ b/js/src/domains/planning/components/budgeting/Budgeting.tsx @@ -1,9 +1,10 @@ import React, { FC, useMemo, useState } from 'react'; import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; -import { Box, Grid, Typography } from '@mui/material'; +import { Box, Chip, Grid, Typography } from '@mui/material'; import { useSafeIntl } from 'bluesquare-components'; import InputComponent from 'Iaso/components/forms/InputComponent'; import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; +import { SxStyles } from 'Iaso/types/general'; import { IconBoxed } from '../../../../components/IconBoxed'; import { PaperContainer } from '../../../../components/styledComponents'; import { MESSAGES } from '../../../messages'; @@ -18,9 +19,40 @@ import { CostBreakdownChart } from './CostBreakdownChart'; import { OrgUnitCostMap } from './OrgUnitCostMap'; import { ProportionChart } from './ProportionChart'; +const styles: SxStyles = { + toolbar: { + py: 1, + px: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: 'white', + borderRadius: 4, + }, + toolbarLeft: { + display: 'flex', + alignItems: 'center', + }, + budgetLabel: { + mx: 2, + }, + toolbarRight: { + display: 'flex', + alignItems: 'center', + gap: 1, + }, + mapGrid: { + height: '493px', + }, + mapContainer: { + height: '100%', + }, +}; + type Props = { budgets: Budget[]; orgUnits: OrgUnit[]; + filterLabel?: string; }; /** Group items by key, merging duplicates by summing via `merge`. */ @@ -39,9 +71,14 @@ function mergeByKey( } function mergeCostLines(lines: BudgetInterventionCostLine[]) { + // Normalize cost_class → category so chart lookups work uniformly + const normalized = lines.map(l => ({ + ...l, + category: l.category || l.cost_class, + })); return mergeByKey( - lines, - l => l.category || l.cost_class, + normalized, + l => l.category, (a, b) => ({ ...a, cost: a.cost + b.cost }), ); } @@ -72,15 +109,11 @@ function mergeOrgUnits(orgUnits: BudgetOrgUnit[]) { ...(a.interventions ?? []), ...(b.interventions ?? []), ]), - cost_breakdown: mergeCostLines([ - ...(a.cost_breakdown ?? []), - ...(b.cost_breakdown ?? []), - ]), }), ); } -export const Budgeting: FC = ({ budgets, orgUnits }) => { +export const Budgeting: FC = ({ budgets, orgUnits, filterLabel }) => { const { formatMessage } = useSafeIntl(); const [selectedYear, setSelectedYear] = useState(0); const defaultBudgetOption = useMemo( @@ -101,14 +134,29 @@ export const Budgeting: FC = ({ budgets, orgUnits }) => { ], [budgets, defaultBudgetOption], ); - const interventionCosts = useMemo( - () => - selectedYear || yearOptions.length <= 2 // only one year in selection - ? budgets.find(b => b.year === selectedYear)?.interventions - : mergeInterventions(budgets.flatMap(b => b.interventions)), - [budgets, yearOptions, selectedYear], + + const orgUnitIds = useMemo( + () => new Set(orgUnits.map(ou => ou.id)), + [orgUnits], ); + const orgUnitCosts = useMemo(() => { + const allOrgUnitCosts = + selectedYear || yearOptions.length <= 2 // only one year in selection + ? budgets.find(b => b.year === selectedYear)?.org_units_costs + : mergeOrgUnits(budgets.flatMap(b => b.org_units_costs)); + + return allOrgUnitCosts?.filter(ouc => orgUnitIds.has(ouc.org_unit_id)); + }, [budgets, selectedYear, yearOptions, orgUnitIds]); + + const interventionCosts = useMemo(() => { + if (!orgUnitCosts) return []; + + return mergeInterventions( + orgUnitCosts.flatMap(ouc => ouc.interventions ?? []), + ); + }, [orgUnitCosts]); + const sortedInterventionCosts = useMemo(() => { if (!interventionCosts) return []; @@ -117,14 +165,6 @@ export const Budgeting: FC = ({ budgets, orgUnits }) => { ); }, [interventionCosts]); - const orgUnitCosts = useMemo( - () => - selectedYear || yearOptions.length <= 2 // only one year in selection - ? budgets.find(b => b.year === selectedYear)?.org_units_costs - : mergeOrgUnits(budgets.flatMap(b => b.org_units_costs)), - [budgets, selectedYear, yearOptions], - ); - const totalCost = useMemo(() => { if (!interventionCosts) return 0; @@ -135,20 +175,10 @@ export const Budgeting: FC = ({ budgets, orgUnits }) => { <> {yearOptions && yearOptions.length > 2 && ( - - + + - + {formatMessage(MESSAGES.budget)} @@ -165,10 +195,15 @@ export const Budgeting: FC = ({ budgets, orgUnits }) => { clearable={false} /> - - {formatMessage(MESSAGES.total)} :{' '} - {formatBigNumber(totalCost)} - + + + {formatMessage(MESSAGES.budget)} :{' '} + {formatBigNumber(totalCost)} + + {filterLabel && ( + + )} + )} @@ -188,8 +223,8 @@ export const Budgeting: FC = ({ budgets, orgUnits }) => { /> - - + + = ({ interventionPlans, isLoadingPlans, totalOrgUnitCount = 0, + displayOrgUnitId, }) => { const [tabValue, setTabValue] = useState('map'); const [interventionCategory, setInterventionCategory] = @@ -170,6 +172,7 @@ export const InterventionsPlan: FC = ({ interventions={ interventionCategory?.interventions || [] } + displayOrgUnitId={displayOrgUnitId} /> diff --git a/js/src/domains/planning/components/interventionPlan/InterventionsPlanMap.tsx b/js/src/domains/planning/components/interventionPlan/InterventionsPlanMap.tsx index ddaca373..ab32dc26 100644 --- a/js/src/domains/planning/components/interventionPlan/InterventionsPlanMap.tsx +++ b/js/src/domains/planning/components/interventionPlan/InterventionsPlanMap.tsx @@ -42,6 +42,7 @@ type Props = { scenarioId: number | undefined; disabled?: boolean; interventions: Intervention[]; + displayOrgUnitId?: number | null; }; const styles: SxStyles = { @@ -71,9 +72,11 @@ export const InterventionsPlanMap: FunctionComponent = ({ scenarioId, disabled = false, interventions = [], + displayOrgUnitId, }) => { const { formatMessage } = useSafeIntl(); - const { data: orgUnits, isLoading: loadingOrgUnits } = useGetOrgUnits(); + const { data: orgUnits, isLoading: loadingOrgUnits } = + useGetOrgUnits(displayOrgUnitId); const [editMode, setEditMode] = useState(false); const [selectedOrgUnits, setSelectedOrgUnits] = useState([]); const [selectedInterventionId, setSelectedInterventionId] = useState< diff --git a/js/src/domains/planning/components/map.tsx b/js/src/domains/planning/components/map.tsx index 97c4f8c6..590bf848 100644 --- a/js/src/domains/planning/components/map.tsx +++ b/js/src/domains/planning/components/map.tsx @@ -9,6 +9,8 @@ import tiles from 'Iaso/constants/mapTiles'; import { OrgUnit } from 'Iaso/domains/orgUnits/types/orgUnit'; import { SxStyles } from 'Iaso/types/general'; import { Bounds } from 'Iaso/utils/map/mapUtils'; +import { FitBounds } from '../../../components/FitBounds'; +import { InvalidateOnResize } from '../../../components/InvalidateOnResize'; import { MapSelectionWidget } from '../../../components/MapSelectionWidget'; import { mapTheme } from '../../../constants/map-theme'; import { @@ -156,6 +158,11 @@ export const Map: FC = ({ zoomSnap={defaultZoomSnap} zoomDelta={defaultZoomDelta} > + + {orgUnits.map(orgUnit => ( = ({ orgUnits, initialDisplayedMetric }) => { zoomSnap={defaultZoomSnap} zoomDelta={defaultZoomDelta} > + + {orgUnits.map(orgUnit => ( diff --git a/js/src/domains/planning/hooks/useGetOrgUnits.tsx b/js/src/domains/planning/hooks/useGetOrgUnits.tsx index ec08514f..b64769cc 100644 --- a/js/src/domains/planning/hooks/useGetOrgUnits.tsx +++ b/js/src/domains/planning/hooks/useGetOrgUnits.tsx @@ -4,13 +4,18 @@ import { getRequest } from 'Iaso/libs/Api'; import { useSnackQuery } from 'Iaso/libs/apiHooks'; import { makeUrlWithParams } from 'Iaso/libs/utils'; -export const useGetOrgUnits = (): UseQueryResult => { +export const useGetOrgUnits = ( + orgUnitParentId?: number | null, +): UseQueryResult => { const params: Record = { validation_status: 'VALID', defaultVersion: true, asLocation: true, limit: 8000, }; + if (orgUnitParentId) { + params.orgUnitParentId = orgUnitParentId; + } const url = makeUrlWithParams('/api/orgunits/', params); return useSnackQuery({ queryKey: ['orgUnits', params], @@ -20,3 +25,32 @@ export const useGetOrgUnits = (): UseQueryResult => { }, }); }; + +type OrgUnitsResponse = { + orgunits: OrgUnit[]; +}; + +export const useGetOrgUnitsByType = ( + orgUnitTypeId: number | null, +): UseQueryResult => { + const params: Record = { + validation_status: 'VALID', + defaultVersion: true, + limit: 8000, + smallSearch: true, + order: 'name', + }; + if (orgUnitTypeId) { + params.orgUnitTypeId = orgUnitTypeId; + } + const url = makeUrlWithParams('/api/orgunits/', params); + return useSnackQuery({ + queryKey: ['orgUnitsByType', params], + queryFn: () => getRequest(url) as Promise, + options: { + enabled: orgUnitTypeId !== null, + cacheTime: Infinity, + select: (data: OrgUnitsResponse) => data.orgunits ?? [], + }, + }); +}; diff --git a/js/src/domains/planning/hooks/useGetSNTConfig.ts b/js/src/domains/planning/hooks/useGetSNTConfig.ts new file mode 100644 index 00000000..92cf064a --- /dev/null +++ b/js/src/domains/planning/hooks/useGetSNTConfig.ts @@ -0,0 +1,20 @@ +import { UseQueryResult } from 'react-query'; +import { getRequest } from 'Iaso/libs/Api'; +import { useSnackQuery } from 'Iaso/libs/apiHooks'; + +type SntConfig = { + intervention_org_unit_type_id: number; + country_org_unit_type_id: number; +}; + +export const useGetSNTConfig = (): UseQueryResult => { + return useSnackQuery({ + queryKey: ['snt_malaria_config'], + queryFn: () => getRequest('/api/datastore/snt_malaria_config/'), + options: { + select: response => response.data, + retry: false, + }, + ignoreErrorCodes: [404], + }); +}; diff --git a/js/src/domains/planning/index.tsx b/js/src/domains/planning/index.tsx index af1474ef..f32ee62f 100644 --- a/js/src/domains/planning/index.tsx +++ b/js/src/domains/planning/index.tsx @@ -1,6 +1,10 @@ -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { Grid } from '@mui/material'; -import { LoadingSpinner, useSafeIntl } from 'bluesquare-components'; +import { + LoadingSpinner, + useRedirectToReplace, + useSafeIntl, +} from 'bluesquare-components'; import TopBar from 'Iaso/components/nav/TopBarComponent'; import { openSnackBar } from 'Iaso/components/snackBars/EventDispatcher'; import { succesfullSnackBar } from 'Iaso/constants/snackBars'; @@ -21,6 +25,7 @@ import { InterventionAssignments } from './components/interventionAssignment/Int import { InterventionsPlan } from './components/interventionPlan/InterventionsPlan'; import { Map } from './components/map'; import { SideMapList } from './components/maps/SideMapList'; +import { PlanningFiltersSidebar } from './components/PlanningFiltersSidebar'; import { ScenarioTopBar } from './components/ScenarioTopBar'; import { useGetInterventionAssignments } from './hooks/useGetInterventionAssignments'; import { useGetLatestCalculatedBudget } from './hooks/useGetLatestCalculatedBudget'; @@ -29,12 +34,14 @@ import { useGetMetricOrgUnits, useGetMetricValues, } from './hooks/useGetMetrics'; -import { useGetOrgUnits } from './hooks/useGetOrgUnits'; +import { useGetOrgUnits, useGetOrgUnitsByType } from './hooks/useGetOrgUnits'; import { Intervention } from './types/interventions'; import { MetricsFilters, MetricType } from './types/metrics'; type PlanningParams = { - scenarioId: number; + scenarioId: string; + displayOrgUnitTypeId?: string; + displayOrgUnitId?: string; }; const styles: SxStyles = { @@ -45,12 +52,60 @@ export const Planning: FC = () => { const params = useParamsObject( baseUrls.planning, ) as unknown as PlanningParams; + const redirectToReplace = useRedirectToReplace(); const { data: scenario } = useGetScenario(params.scenarioId); - const { data: orgUnits, isLoading: isLoadingOrgUnits } = useGetOrgUnits(); const { formatMessage } = useSafeIntl(); const [metricFilters, setMetricFilters] = useState(); const [selectionOnMap, setSelectionOnMap] = useState([]); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + // Read display options from URL params + const selectedDisplayOrgUnitTypeId = params.displayOrgUnitTypeId + ? Number(params.displayOrgUnitTypeId) + : null; + const selectedDisplayOrgUnitId = params.displayOrgUnitId + ? Number(params.displayOrgUnitId) + : null; + + // Fetch org units with optional parent filter (API handles filtering) + const { data: orgUnits, isLoading: isLoadingOrgUnits } = useGetOrgUnits( + selectedDisplayOrgUnitId, + ); + + // Look up the selected display org unit name (served from React Query cache) + const { data: orgUnitsByType } = useGetOrgUnitsByType( + selectedDisplayOrgUnitTypeId, + ); + const selectedDisplayOrgUnitName = selectedDisplayOrgUnitId + ? orgUnitsByType?.find(ou => ou.id === selectedDisplayOrgUnitId)?.name + : undefined; + + const handleDisplayOrgUnitTypeChange = useCallback( + (orgUnitTypeId: number | null) => { + redirectToReplace(baseUrls.planning, { + ...params, + displayOrgUnitTypeId: orgUnitTypeId ?? undefined, + displayOrgUnitId: undefined, // Clear org unit when type changes + }); + // Clear selection when filter changes + setSelectionOnMap([]); + }, + [params, redirectToReplace], + ); + + const handleDisplayOrgUnitChange = useCallback( + (orgUnitId: number | null) => { + redirectToReplace(baseUrls.planning, { + ...params, + displayOrgUnitId: orgUnitId ?? undefined, + }); + // Clear selection when filter changes + setSelectionOnMap([]); + }, + [params, redirectToReplace], + ); + const [selectedInterventions, setSelectedInterventions] = useState<{ [categoryId: number]: Intervention; }>({}); @@ -66,6 +121,21 @@ export const Planning: FC = () => { const { data: interventionPlans, isLoading: isLoadingPlans } = useGetInterventionAssignments(scenario?.id); + const filteredInterventionPlans = useMemo(() => { + if (!interventionPlans) return []; + if (!orgUnits) return interventionPlans; + const filteredOrgUnitIds = new Set(orgUnits.map(ou => ou.id)); + + return interventionPlans + .map(plan => ({ + ...plan, + org_units: plan.org_units.filter(ou => + filteredOrgUnitIds.has(ou.id), + ), + })) + .filter(plan => plan.org_units.length > 0); + }, [interventionPlans, orgUnits]); + useEffect(() => { if ( metricCategories && @@ -161,7 +231,13 @@ export const Planning: FC = () => { {isLoadingOrgUnits && } - {scenario && } + {scenario && ( + setIsSidebarOpen(prev => !prev)} + /> + )} @@ -191,7 +267,14 @@ export const Planning: FC = () => { - + {isLoading &&

Loading data...

} {metricCategories && orgUnits && ( @@ -202,6 +285,22 @@ export const Planning: FC = () => { )}
+ {isSidebarOpen && orgUnits && ( + + + + + + )}
{scenario?.is_locked ? null : ( @@ -232,15 +331,17 @@ export const Planning: FC = () => { {orgUnits && budget && ( )}
diff --git a/js/src/domains/planning/types/budget.ts b/js/src/domains/planning/types/budget.ts index 9d4eb2e0..400b9018 100644 --- a/js/src/domains/planning/types/budget.ts +++ b/js/src/domains/planning/types/budget.ts @@ -10,7 +10,6 @@ export type BudgetOrgUnit = { org_unit_id: number; total_cost: number; interventions: BudgetIntervention[]; - cost_breakdown: BudgetInterventionCostLine[]; }; export type BudgetIntervention = { diff --git a/management/commands/recreate_demo_account.py b/management/commands/recreate_demo_account.py index 24cd7579..35c40d08 100644 --- a/management/commands/recreate_demo_account.py +++ b/management/commands/recreate_demo_account.py @@ -6,6 +6,8 @@ from iaso.gpkg.import_gpkg import import_gpkg_file2 from iaso.models import Account, DataSource, MetricType, MetricValue, Profile, Project, Report, SourceVersion, Team +from iaso.models.data_store import JsonDataStore +from iaso.permissions.core_permissions import CORE_DATASTORE_READ_PERMISSION from plugins.snt_malaria.models import ( Budget, Intervention, @@ -42,18 +44,24 @@ def delete_existing_demo_account(self): demo_account = Account.objects.get(name=DEMO_ACCOUNT_NAME) projects = Project.objects.filter(account=demo_account) data_sources = DataSource.objects.filter(projects__in=projects) - scenarios = Scenario.objects.filter(account=demo_account) - categories = InterventionCategory.objects.filter(account=demo_account) - interventions = Intervention.objects.filter(intervention_category__in=categories) + JsonDataStore.objects.filter(account=demo_account).delete() + scenarios = Scenario.objects_include_deleted.filter(account=demo_account) + categories = InterventionCategory.objects_include_deleted.filter(account=demo_account) + interventions = Intervention.objects_include_deleted.filter(intervention_category__in=categories) metric_types = MetricType.objects.filter(account=demo_account) # Delete in order to avoid PROTECT foreign key constraints InterventionAssignment.objects.filter(scenario__in=scenarios).delete() - Budget.objects.filter(scenario__in=scenarios).delete() - scenarios.delete() + budgets = Budget.objects_include_deleted.filter(scenario__in=scenarios) + for b in budgets: + b.delete_hard() + for s in scenarios: + s.delete_hard() InterventionCostBreakdownLine.objects.filter(intervention__in=interventions).delete() - interventions.delete() - categories.delete() + for i in interventions: + i.delete_hard() + for c in categories: + c.delete_hard() MetricValue.objects.filter(metric_type__in=metric_types).delete() metric_types.delete() Team.objects.filter(project__in=projects).delete() @@ -84,6 +92,7 @@ def handle(self, *args, **options): snt_permission_codenames = [ permission.codename for permission in [ + CORE_DATASTORE_READ_PERMISSION, SNT_SCENARIO_BASIC_WRITE_PERMISSION, SNT_SCENARIO_FULL_WRITE_PERMISSION, SNT_SETTINGS_READ_PERMISSION, @@ -153,7 +162,7 @@ def handle(self, *args, **options): InterventionSeeder(account, self.stdout.write).create_interventions() # Create demo scenario with intervention assignments - DemoScenarioSeeder(account, self.stdout.write).create_scenario() + DemoScenarioSeeder(account, project, self.stdout.write).create_scenario() self.stdout.write(self.style.SUCCESS("Setup completed successfully:")) self.stdout.write( diff --git a/management/commands/support/demo_scenario_seeder.py b/management/commands/support/demo_scenario_seeder.py index e3811c87..2a9384bc 100644 --- a/management/commands/support/demo_scenario_seeder.py +++ b/management/commands/support/demo_scenario_seeder.py @@ -8,7 +8,8 @@ from snt_malaria_budgeting import DEFAULT_COST_ASSUMPTIONS, BudgetCalculator -from iaso.models import User +from iaso.models import User, OrgUnitType +from iaso.models.data_store import JsonDataStore from iaso.models.metric import MetricValue from plugins.snt_malaria.api.budget.utils import ( build_cost_dataframe, @@ -21,8 +22,9 @@ class DemoScenarioSeeder: - def __init__(self, account, stdout_writer=None): + def __init__(self, account, project, stdout_writer=None): self.account = account + self.project = project self.stdout_write = stdout_writer or print def create_scenario(self): @@ -152,6 +154,18 @@ def create_scenario(self): budget = self._create_budget_for_scenario(scenario, created_by) self.stdout_write(f"Created budget: {budget.name}") + # Create an SNT config + country_out_id = OrgUnitType.objects.get(projects=self.project, short_name="Country").id + district_out_id = OrgUnitType.objects.get(projects=self.project, short_name="District").id + JsonDataStore.objects.create( + slug="snt_malaria_config", + account=self.account, + content={ + "country_org_unit_type_id": country_out_id, + "intervention_org_unit_type_id": district_out_id, + }, + ) + self.stdout_write("Done creating demo scenario.") return scenario diff --git a/models/budget.py b/models/budget.py index 6071c2c5..5c85e9ae 100644 --- a/models/budget.py +++ b/models/budget.py @@ -1,7 +1,12 @@ from django.contrib.auth.models import User from django.db import models -from iaso.utils.models.soft_deletable import SoftDeletableModel +from iaso.utils.models.soft_deletable import ( + DefaultSoftDeletableManager, + IncludeDeletedSoftDeletableManager, + OnlyDeletedSoftDeletableManager, + SoftDeletableModel, +) from plugins.snt_malaria.models.scenario import Scenario @@ -21,3 +26,7 @@ class Meta: # TODO: Not sure we will need this, but I'd rather have them already. updated_at = models.DateTimeField(auto_now=True) updated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name="budget_edited_set") + + objects = DefaultSoftDeletableManager() + objects_only_deleted = OnlyDeletedSoftDeletableManager() + objects_include_deleted = IncludeDeletedSoftDeletableManager() diff --git a/models/budget_settings.py b/models/budget_settings.py index 0d09dad3..cd56b0a5 100644 --- a/models/budget_settings.py +++ b/models/budget_settings.py @@ -1,6 +1,11 @@ from django.db import models -from iaso.utils.models.soft_deletable import SoftDeletableModel +from iaso.utils.models.soft_deletable import ( + DefaultSoftDeletableManager, + IncludeDeletedSoftDeletableManager, + OnlyDeletedSoftDeletableManager, + SoftDeletableModel, +) class BudgetSettings(SoftDeletableModel): @@ -11,3 +16,7 @@ class Meta: local_currency = models.CharField(max_length=3) exchange_rate = models.DecimalField(max_digits=20, decimal_places=10) inflation_rate = models.DecimalField(max_digits=20, decimal_places=10) + + objects = DefaultSoftDeletableManager() + objects_only_deleted = OnlyDeletedSoftDeletableManager() + objects_include_deleted = IncludeDeletedSoftDeletableManager() diff --git a/models/intervention.py b/models/intervention.py index fa3217f0..186379ab 100644 --- a/models/intervention.py +++ b/models/intervention.py @@ -4,6 +4,9 @@ from iaso.models import OrgUnit from iaso.utils.models.soft_deletable import ( + DefaultSoftDeletableManager, + IncludeDeletedSoftDeletableManager, + OnlyDeletedSoftDeletableManager, SoftDeletableModel, ) from plugins.snt_malaria.models.scenario import Scenario, ScenarioRule @@ -24,6 +27,10 @@ class Meta: created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) + objects = DefaultSoftDeletableManager() + objects_only_deleted = OnlyDeletedSoftDeletableManager() + objects_include_deleted = IncludeDeletedSoftDeletableManager() + def __str__(self): return "%s %s" % (self.name, self.id) @@ -46,6 +53,10 @@ class Meta: User, on_delete=models.SET_NULL, null=True, blank=True, related_name="intervention_updated_set" ) + objects = DefaultSoftDeletableManager() + objects_only_deleted = OnlyDeletedSoftDeletableManager() + objects_include_deleted = IncludeDeletedSoftDeletableManager() + def __str__(self): return "%s %s" % (self.name, self.id) diff --git a/models/scenario.py b/models/scenario.py index cfd1d25a..b9608291 100644 --- a/models/scenario.py +++ b/models/scenario.py @@ -7,6 +7,8 @@ from iaso.utils.models.color import ColorField from iaso.utils.models.soft_deletable import ( DefaultSoftDeletableManager, + IncludeDeletedSoftDeletableManager, + OnlyDeletedSoftDeletableManager, SoftDeletableModel, ) from iaso.utils.validators import JSONSchemaValidator @@ -35,6 +37,8 @@ class Meta: updated_at = models.DateTimeField(auto_now=True) objects = DefaultSoftDeletableManager() + objects_only_deleted = OnlyDeletedSoftDeletableManager() + objects_include_deleted = IncludeDeletedSoftDeletableManager() def __str__(self): return "%s %s %s" % (self.name, self.updated_at, self.id)