From e2ef241b353562d9951e862376ad8c704c0a5499 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Tue, 10 Sep 2024 15:11:51 +0200 Subject: [PATCH 01/29] Add datastore and getProgramIndicatorsFromDatastore common function --- src/data/DataStoreClient.ts | 28 ++++++++++++++++ .../getProgramIndicatorsFromDatastore.ts | 32 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 src/data/DataStoreClient.ts create mode 100644 src/data/repositories/common/getProgramIndicatorsFromDatastore.ts diff --git a/src/data/DataStoreClient.ts b/src/data/DataStoreClient.ts new file mode 100644 index 00000000..8fe0910b --- /dev/null +++ b/src/data/DataStoreClient.ts @@ -0,0 +1,28 @@ +import { D2Api, DataStore } from "@eyeseetea/d2-api/2.36"; +import { apiToFuture, FutureData } from "./api-futures"; + +export const dataStoreNamespace = "zebra"; + +export class DataStoreClient { + private dataStore: DataStore; + + constructor(private api: D2Api) { + this.dataStore = this.api.dataStore(dataStoreNamespace); + } + + public listCollection(key: string): FutureData { + return apiToFuture(this.dataStore.get(key)).map(data => data ?? []); + } + + public getObject(key: string): FutureData { + return apiToFuture(this.dataStore.get(key)); + } + + public saveObject(key: string, value: T): FutureData { + return apiToFuture(this.dataStore.save(key, value)); + } + + public removeObject(key: string): FutureData { + return apiToFuture(this.dataStore.delete(key)); + } +} diff --git a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts new file mode 100644 index 00000000..065548ad --- /dev/null +++ b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts @@ -0,0 +1,32 @@ +import { Maybe } from "../../../utils/ts-utils"; +import { FutureData } from "../../api-futures"; +import { DataStoreClient } from "../../DataStoreClient"; + +export enum ProgramIndicatorsDatastoreKey { + ActiveVerifiedAlerts = "active-verified-alerts-program-indicators", + CasesAlerts = "cases-alerts-program-indicators", +} + +export type ProgramIndicatorsDatastore = { + id: string; + name: string; + disease: string | null; + hazardType: string | null; + incidentStatus: string | null; +}; + +export function getProgramIndicatorsFromDatastore( + dataStoreClient: DataStoreClient, + programIndicatorsDatastoreKey: ProgramIndicatorsDatastoreKey +): FutureData> { + switch (programIndicatorsDatastoreKey) { + case ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts: + return dataStoreClient.getObject( + programIndicatorsDatastoreKey + ); + case ProgramIndicatorsDatastoreKey.CasesAlerts: + return dataStoreClient.getObject( + programIndicatorsDatastoreKey + ); + } +} From 84abf73ce051021e83393add9d1ce50213e3dbe4 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 09:54:38 +0200 Subject: [PATCH 02/29] Add Map in Dashboards page --- i18n/en.pot | 7 +- i18n/es.po | 5 +- src/CompositionRoot.ts | 10 + .../repositories/AnalyticsD2Repository.ts | 14 +- .../repositories/MapConfigD2Repository.ts | 95 +++ .../getProgramIndicatorsFromDatastore.ts | 6 +- .../repositories/consts/AnalyticsConstants.ts | 39 +- .../test/MapConfigTestRepository.ts | 610 ++++++++++++++++++ src/domain/entities/MapConfig.ts | 28 + .../repositories/MapConfigRepository.ts | 6 + src/domain/usecases/GetMapConfigUseCase.ts | 11 + src/utils/tests.tsx | 3 + .../components/loader/LoaderContainer.tsx | 20 + src/webapp/components/map/Map.tsx | 144 +++++ src/webapp/contexts/app-context.ts | 3 + src/webapp/pages/app/App.tsx | 9 +- src/webapp/pages/app/Dhis2App.tsx | 7 +- src/webapp/pages/app/__tests__/App.spec.tsx | 4 +- src/webapp/pages/dashboard/DashboardPage.tsx | 5 + src/webapp/pages/dashboard/map/MapSection.tsx | 45 ++ src/webapp/pages/dashboard/map/useMap.ts | 183 ++++++ 21 files changed, 1221 insertions(+), 33 deletions(-) create mode 100644 src/data/repositories/MapConfigD2Repository.ts create mode 100644 src/data/repositories/test/MapConfigTestRepository.ts create mode 100644 src/domain/entities/MapConfig.ts create mode 100644 src/domain/repositories/MapConfigRepository.ts create mode 100644 src/domain/usecases/GetMapConfigUseCase.ts create mode 100644 src/webapp/components/loader/LoaderContainer.tsx create mode 100644 src/webapp/components/map/Map.tsx create mode 100644 src/webapp/pages/dashboard/map/MapSection.tsx create mode 100644 src/webapp/pages/dashboard/map/useMap.ts diff --git a/i18n/en.pot b/i18n/en.pot index 2fc2b9ca..40e51341 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-04T07:57:22.036Z\n" -"PO-Revision-Date: 2024-09-04T07:57:22.036Z\n" +"POT-Creation-Date: 2024-09-10T13:12:30.774Z\n" +"PO-Revision-Date: 2024-09-10T13:12:30.774Z\n" msgid "Low" msgstr "" @@ -102,6 +102,9 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" +msgid "All public health events" +msgstr "" + msgid "Performance overview" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 21452063..0f1e2d3c 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-04T07:57:22.036Z\n" +"POT-Creation-Date: 2024-09-10T13:12:30.774Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -101,6 +101,9 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" +msgid "All public health events" +msgstr "" + msgid "Performance overview" msgstr "" diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 221208e7..9c8e2ffe 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -24,6 +24,10 @@ import { GetAllProgramIndicatorsUseCase } from "./domain/usecases/GetAllProgramI import { AnalyticsD2Repository } from "./data/repositories/AnalyticsD2Repository"; import { ProgramIndicatorsTestRepository } from "./data/repositories/test/ProgramIndicatorsTestRepository"; import { GetDiseasesTotalUseCase } from "./domain/usecases/GetDiseasesTotalUseCase"; +import { MapConfigRepository } from "./domain/repositories/MapConfigRepository"; +import { MapConfigD2Repository } from "./data/repositories/MapConfigD2Repository"; +import { MapConfigTestRepository } from "./data/repositories/test/MapConfigTestRepository"; +import { GetMapConfigUseCase } from "./domain/usecases/GetMapConfigUseCase"; export type CompositionRoot = ReturnType; @@ -34,6 +38,7 @@ type Repositories = { teamMemberRepository: TeamMemberRepository; orgUnitRepository: OrgUnitRepository; analytics: AnalyticsRepository; + mapConfigRepository: MapConfigRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -51,6 +56,9 @@ function getCompositionRoot(repositories: Repositories) { getProgramIndicators: new GetAllProgramIndicatorsUseCase(repositories), getDiseasesTotal: new GetDiseasesTotalUseCase(repositories), }, + maps: { + getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), + }, }; } @@ -62,6 +70,7 @@ export function getWebappCompositionRoot(api: D2Api) { teamMemberRepository: new TeamMemberD2Repository(api), orgUnitRepository: new OrgUnitD2Repository(api), analytics: new AnalyticsD2Repository(api), + mapConfigRepository: new MapConfigD2Repository(api), }; return getCompositionRoot(repositories); @@ -75,6 +84,7 @@ export function getTestCompositionRoot() { teamMemberRepository: new TeamMemberTestRepository(), orgUnitRepository: new OrgUnitTestRepository(), analytics: new ProgramIndicatorsTestRepository(), + mapConfigRepository: new MapConfigTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/AnalyticsD2Repository.ts b/src/data/repositories/AnalyticsD2Repository.ts index d5146221..549f8614 100644 --- a/src/data/repositories/AnalyticsD2Repository.ts +++ b/src/data/repositories/AnalyticsD2Repository.ts @@ -164,11 +164,7 @@ export class AnalyticsD2Repository implements AnalyticsRepository { }: Record) => { const cases = this.calculateTotals(nbOfCasesByDiseaseFuture, NB_OF_CASES); const deaths = this.calculateTotals(nbOfDeathsByDiseaseFuture, NB_OF_DEATHS); - console.log({ - indicatorsProgramFuture, - nbOfCasesByDiseaseFuture, - nbOfDeathsByDiseaseFuture, - }); + return ( indicatorsProgramFuture?.rows.map((row: string[]) => { return this.mapRowToIndicator( @@ -213,10 +209,14 @@ export class AnalyticsD2Repository implements AnalyticsRepository { if (!key) return acc; + // TODO: FIXME Fix TypeScript, do not use any if (key === "suspectedDisease") { acc[key] = - Object.values(metaData.items).find(item => item.code === row[index])?.name || - ""; + ( + Object.values(metaData.items).find( + (item: any) => item.code === row[index] + ) as any + )?.name || ""; acc.cases = cases[acc.suspectedDisease]?.toString() || ""; acc.deaths = deaths[acc.suspectedDisease]?.toString() || ""; } else if (key === "eventDetectionDate") { diff --git a/src/data/repositories/MapConfigD2Repository.ts b/src/data/repositories/MapConfigD2Repository.ts new file mode 100644 index 00000000..94b383c4 --- /dev/null +++ b/src/data/repositories/MapConfigD2Repository.ts @@ -0,0 +1,95 @@ +import { Future } from "../../domain/entities/generic/Future"; +import { + MapKey, + MapConfig, + MapProgramIndicatorsDatastoreKey, + MAP_CURRENT_APP, +} from "../../domain/entities/MapConfig"; +import { MapConfigRepository } from "../../domain/repositories/MapConfigRepository"; +import { D2Api } from "../../types/d2-api"; +import { FutureData } from "../api-futures"; +import { DataStoreClient, dataStoreNamespace } from "../DataStoreClient"; +import { + getProgramIndicatorsFromDatastore, + ProgramIndicatorsDatastore, + ProgramIndicatorsDatastoreKey, +} from "./common/getProgramIndicatorsFromDatastore"; + +const MAPS_CONFIG_KEY = "maps-config"; + +export class MapConfigD2Repository implements MapConfigRepository { + private dataStoreClient: DataStoreClient; + + constructor(private api: D2Api) { + this.dataStoreClient = new DataStoreClient(api); + } + + public get(mapKey: MapKey): FutureData { + const programIndicatorsDatastoreKey = + mapKey === "dashboard" + ? ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts + : ProgramIndicatorsDatastoreKey.CasesAlerts; + return this.dataStoreClient + .getObject(MAPS_CONFIG_KEY) + .flatMap(mapsConfigDatastore => { + if (!mapsConfigDatastore) + return Future.error( + new Error( + `Maps configuration not found in datastore: key ${MAPS_CONFIG_KEY}` + ) + ); + + const mapConfigDataStore = + mapKey === "dashboard" + ? mapsConfigDatastore?.dashboard + : mapsConfigDatastore?.event_tracker; + return getProgramIndicatorsFromDatastore( + this.dataStoreClient, + programIndicatorsDatastoreKey + ).flatMap(programIndicatorsDatastore => { + if (!programIndicatorsDatastore) + return Future.error( + new Error( + `Program indicators needed for the map not found in datastore: key ${programIndicatorsDatastoreKey}` + ) + ); + + return Future.success( + this.buildMapConfig(mapConfigDataStore, programIndicatorsDatastore) + ); + }); + }); + } + + private buildMapConfig( + mapConfigDatastore: MapConfigDatastore, + programIndicatorsDatastore: ProgramIndicatorsDatastore[] + ): MapConfig { + return { + currentApp: MAP_CURRENT_APP, + currentPage: mapConfigDatastore.currentPage, + mapId: mapConfigDatastore.mapId, + programId: mapConfigDatastore.programId, + programName: mapConfigDatastore.programName, + startDate: mapConfigDatastore.startDate, + timeField: mapConfigDatastore.timeField, + programIndicators: programIndicatorsDatastore, + zebraNamespace: dataStoreNamespace, + dashboardDatastoreKey: MapProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts, + }; + } +} + +type MapsConfigDatastore = { + dashboard: MapConfigDatastore; + event_tracker: MapConfigDatastore; +}; + +type MapConfigDatastore = { + currentPage: string; + mapId: string; + programId: string; + programName: string; + startDate: string; + timeField: string; +}; diff --git a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts index 065548ad..d6a6b7e1 100644 --- a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts +++ b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts @@ -18,14 +18,14 @@ export type ProgramIndicatorsDatastore = { export function getProgramIndicatorsFromDatastore( dataStoreClient: DataStoreClient, programIndicatorsDatastoreKey: ProgramIndicatorsDatastoreKey -): FutureData> { +): FutureData> { switch (programIndicatorsDatastoreKey) { case ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts: - return dataStoreClient.getObject( + return dataStoreClient.getObject( programIndicatorsDatastoreKey ); case ProgramIndicatorsDatastoreKey.CasesAlerts: - return dataStoreClient.getObject( + return dataStoreClient.getObject( programIndicatorsDatastoreKey ); } diff --git a/src/data/repositories/consts/AnalyticsConstants.ts b/src/data/repositories/consts/AnalyticsConstants.ts index eae687f9..7120f455 100644 --- a/src/data/repositories/consts/AnalyticsConstants.ts +++ b/src/data/repositories/consts/AnalyticsConstants.ts @@ -190,16 +190,31 @@ export const NB_OF_ACTIVE_VERIFIED = [ { id: "XieBgoffFRd", type: "disease", name: "Zika fever", incidentStatus: "Watch" }, { id: "tIYANWCiMoR", type: "disease", name: "Zika fever", incidentStatus: "Alert" }, { id: "qJjRR8EwYgB", type: "disease", name: "Zika fever", incidentStatus: "Respond" }, - { id: "gMoRiHe1Z0Z", type: "hazard", name: "Animal type", incidentStatus: "Watch" }, - { id: "tKLdMcWUg9l", type: "hazard", name: "Animal type", incidentStatus: "Alert" }, - { id: "TJhGnX8E7CP", type: "hazard", name: "Animal type", incidentStatus: "Respond" }, - { id: "YfkOUZPhCY1", type: "hazard", name: "Human type", incidentStatus: "Watch" }, - { id: "NzpH7Y76JBw", type: "hazard", name: "Human type", incidentStatus: "Alert" }, - { id: "jWDbWYr85DP", type: "hazard", name: "Human type", incidentStatus: "Respond" }, - { id: "kLtsjiyIzer", type: "hazard", name: "Human and Animal type", incidentStatus: "Watch" }, - { id: "ge4Jwq2MGrF", type: "hazard", name: "Human and Animal type", incidentStatus: "Alert" }, - { id: "GQ6Yg9ZN4xL", type: "hazard", name: "Human and Animal type", incidentStatus: "Respond" }, - { id: "Bu4bafAjFXN", type: "hazard", name: "Environmental type", incidentStatus: "Watch" }, - { id: "z3EbI98pgjG", type: "hazard", name: "Environmental type", incidentStatus: "Alert" }, - { id: "gRcZNqpKyYg", type: "hazard", name: "Environmental type", incidentStatus: "Respond" }, + { id: "gMoRiHe1Z0Z", type: "hazard", name: "Biological: Animal", incidentStatus: "Watch" }, + { id: "tKLdMcWUg9l", type: "hazard", name: "Biological: Animal", incidentStatus: "Alert" }, + { id: "TJhGnX8E7CP", type: "hazard", name: "Biological: Animal", incidentStatus: "Respond" }, + { id: "YfkOUZPhCY1", type: "hazard", name: "Biological: Human", incidentStatus: "Watch" }, + { id: "NzpH7Y76JBw", type: "hazard", name: "Biological: Human", incidentStatus: "Alert" }, + { id: "jWDbWYr85DP", type: "hazard", name: "Biological: Human", incidentStatus: "Respond" }, + { + id: "kLtsjiyIzer", + type: "hazard", + name: "Biological: Human and Animal", + incidentStatus: "Watch", + }, + { + id: "ge4Jwq2MGrF", + type: "hazard", + name: "Biological: Human and Animal", + incidentStatus: "Alert", + }, + { + id: "GQ6Yg9ZN4xL", + type: "hazard", + name: "Biological: Human and Animal", + incidentStatus: "Respond", + }, + { id: "Bu4bafAjFXN", type: "hazard", name: "Environmental", incidentStatus: "Watch" }, + { id: "z3EbI98pgjG", type: "hazard", name: "Environmental", incidentStatus: "Alert" }, + { id: "gRcZNqpKyYg", type: "hazard", name: "Environmental", incidentStatus: "Respond" }, ]; diff --git a/src/data/repositories/test/MapConfigTestRepository.ts b/src/data/repositories/test/MapConfigTestRepository.ts new file mode 100644 index 00000000..4874e8dd --- /dev/null +++ b/src/data/repositories/test/MapConfigTestRepository.ts @@ -0,0 +1,610 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { MapConfig, MapKey } from "../../../domain/entities/MapConfig"; +import { MapConfigRepository } from "../../../domain/repositories/MapConfigRepository"; +import { FutureData } from "../../api-futures"; + +export class MapConfigTestRepository implements MapConfigRepository { + public get(_mapKey: MapKey): FutureData { + return Future.success({ + currentApp: "ZEBRA", + currentPage: "DASHBOARD", + mapId: "HuCCj3yRQDW", + programId: "MQtbs8UkBxy", + programName: "RTSL_ZEBRA_ALERTS", + startDate: "2000-01-01", + timeField: "ENROLLMENT_DATE", + programIndicators: [ + { + id: "OJo5mx3WSDj", + name: "# of active verified", + disease: "ALL", + hazardType: "ALL", + incidentStatus: "ALL", + }, + { + id: "shDqMOCI67z", + name: "# of active verified - Watch", + disease: "ALL", + hazardType: "ALL", + incidentStatus: "Watch", + }, + { + id: "PHhaZK4KeOA", + name: "# of active verified - Alert", + disease: "ALL", + hazardType: "ALL", + incidentStatus: "Alert", + }, + { + id: "e2LtQdabQRv", + name: "# of active verified - Respond", + disease: "ALL", + hazardType: "ALL", + incidentStatus: "Respond", + }, + { + id: "koyK0jI9IS6", + name: "# of active verified - Acute respiratory", + disease: "Acute respiratory", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "X9DtagMllHF", + name: "# of active verified - Acute VHF", + disease: "Acute VHF", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "s46w5UXnmXB", + name: "# of active verified - AFP", + disease: "AFP", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "MzR4s9k6Ne8", + name: "# of active verified - Anthrax", + disease: "Anthrax", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "GvAXv7mcBD0", + name: "# of active verified - Bacterial meningitis", + disease: "Bacterial meningitis", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "DtiXRURD4ZO", + name: "# of active verified - Cholera", + disease: "Cholera", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "TqpD8J4QLiY", + name: "# of active verified - COVID19", + disease: "COVID19", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "Rs3VYVCPGah", + name: "# of active verified - Diarrhoea with blood", + disease: "Diarrhoea with blood", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "zwYANHwcJJN", + name: "# of active verified - Measles", + disease: "Measles", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "BUHp1iUlRz9", + name: "# of active verified - Monkeypox", + disease: "Monkeypox", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "Uk8xG5QfShd", + name: "# of active verified - Neonatal tetanus", + disease: "Neonatal tetanus", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "GX4Qo2Y40eA", + name: "# of active verified - Plague", + disease: "Plague", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "As9Hp478vFJ", + name: "# of active verified - SARIs", + disease: "SARIs", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "dbikN79NQNz", + name: "# of active verified - Typhoid fever", + disease: "Typhoid fever", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "xJpA1n03N5W", + name: "# of active verified - Zika fever", + disease: "Zika fever", + hazardType: null, + incidentStatus: "ALL", + }, + { + id: "SGGbbu0AKUv", + name: "# of active verified - Acute respiratory - Watch", + disease: "Acute respiratory", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "QnhsQnEsp1p", + name: "# of active verified - Acute respiratory - Alert", + disease: "Acute respiratory", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "Rt5KNVqBEO7", + name: "# of active verified - Acute respiratory - Respond", + disease: "Acute respiratory", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "bcI9Rmx2ycH", + name: "# of active verified - Acute VHF - Watch", + disease: "Acute VHF", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "u4XTtjm9nEh", + name: "# of active verified - Acute VHF - Alert", + disease: "Acute VHF", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "gpKelVBHhRZ", + name: "# of active verified - Acute VHF - Respond", + disease: "Acute VHF", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "pqob28cwd3i", + name: "# of active verified - AFP - Watch", + disease: "AFP", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "XqhZBwAzyhZ", + name: "# of active verified - AFP - Alert", + disease: "AFP", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "SyemUCen8zf", + name: "# of active verified - AFP - Respond", + disease: "AFP", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "YPPhLHgwiKV", + name: "# of active verified - Anthrax - Watch", + disease: "Anthrax", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "FhdaufdE8l3", + name: "# of active verified - Anthrax - Alert", + disease: "Anthrax", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "vuhm2b5D076", + name: "# of active verified - Anthrax - Respond", + disease: "Anthrax", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "qeQSDdPTeVq", + name: "# of active verified - Bacterial meningitis - Watch", + disease: "Bacterial meningitis", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "WXlyJHUKI8T", + name: "# of active verified - Bacterial meningitis - Alert", + disease: "Bacterial meningitis", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "DCwOujun1ED", + name: "# of active verified - Bacterial meningitis - Respond", + disease: "Bacterial meningitis", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "zNctWJj7Ncl", + name: "# of active verified - Cholera - Watch", + disease: "Cholera", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "U31oe2BwJtt", + name: "# of active verified - Cholera - Alert", + disease: "Cholera", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "WCrE9mP80q4", + name: "# of active verified - Cholera - Respond", + disease: "Cholera", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "m2LBISybVDA", + name: "# of active verified - COVID19 - Watch", + disease: "COVID19", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "sY5lGlHpcuN", + name: "# of active verified - COVID19 - Alert", + disease: "COVID19", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "LQ128PeTF8x", + name: "# of active verified - COVID19 - Respond", + disease: "COVID19", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "oKSsu6q3MJW", + name: "# of active verified - Diarrhoea with blood - Watch", + disease: "Diarrhoea with blood", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "EgGc7XxZjmC", + name: "# of active verified - Diarrhoea with blood - Alert", + disease: "Diarrhoea with blood", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "uAMXUxp3XBa", + name: "# of active verified - Diarrhoea with blood - Respond", + disease: "Diarrhoea with blood", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "yesuR8ho9vY", + name: "# of active verified - Measles - Watch", + disease: "Measles", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "OvxA9yqaH7q", + name: "# of active verified - Measles - Alert", + disease: "Measles", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "q9HlUfaQj3p", + name: "# of active verified - Measles - Respond", + disease: "Measles", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "mw7Qxti6Fk5", + name: "# of active verified - Monkeypox - Watch", + disease: "Monkeypox", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "kMsSxdZMqJV", + name: "# of active verified - Monkeypox - Alert", + disease: "Monkeypox", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "qL6WGfcoh1l", + name: "# of active verified - Monkeypox - Respond", + disease: "Monkeypox", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "eo2RAoIRYiV", + name: "# of active verified - Neonatal tetanus - Watch", + disease: "Neonatal tetanus", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "EuIc8gJYAhP", + name: "# of active verified - Neonatal tetanus - Alert", + disease: "Neonatal tetanus", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "H7Fmb58GUF9", + name: "# of active verified - Neonatal tetanus - Respond", + disease: "Neonatal tetanus", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "IYktWOGBTtj", + name: "# of active verified - Plague - Watch", + disease: "Plague", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "qdLWFsb7Ghk", + name: "# of active verified - Plague - Alert", + disease: "Plague", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "nbG4Lnl1JUz", + name: "# of active verified - Plague - Respond", + disease: "Plague", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "fEdwx7X6BLI", + name: "# of active verified - SARIs - Watch", + disease: "SARIs", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "FSstKrL8oys", + name: "# of active verified - SARIs - Alert", + disease: "SARIs", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "SkkAznpVZzr", + name: "# of active verified - SARIs - Respond", + disease: "SARIs", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "JcfEcfD64Gy", + name: "# of active verified - Typhoid fever - Watch", + disease: "Typhoid fever", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "wfsBvSq7Hn1", + name: "# of active verified - Typhoid fever - Alert", + disease: "Typhoid fever", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "FMKLwKkOUzx", + name: "# of active verified - Typhoid fever - Respond", + disease: "Typhoid fever", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "XieBgoffFRd", + name: "# of active verified - Zika fever - Watch", + disease: "Zika fever", + hazardType: null, + incidentStatus: "Watch", + }, + { + id: "tIYANWCiMoR", + name: "# of active verified - Zika fever - Alert", + disease: "Zika fever", + hazardType: null, + incidentStatus: "Alert", + }, + { + id: "qJjRR8EwYgB", + name: "# of active verified - Zika fever - Respond", + disease: "Zika fever", + hazardType: null, + incidentStatus: "Respond", + }, + { + id: "wK8Z7XvjUcC", + name: "# of active verified - Animal type", + disease: null, + hazardType: "Biological: Animal", + incidentStatus: "ALL", + }, + { + id: "pmXUt3YWXO1", + name: "# of active verified - Human type", + disease: null, + hazardType: "Biological: Human", + incidentStatus: "ALL", + }, + { + id: "It2z7nRoYn1", + name: "# of active verified - Human and Animal type", + disease: null, + hazardType: "Biological: Human and Animal", + incidentStatus: "ALL", + }, + { + id: null, + name: null, + disease: null, + hazardType: "Chemical (Bio-warfare)", + incidentStatus: "ALL", + }, + { + id: "Xec7fRcZ1wy", + name: "# of active verified - Environmental type", + disease: null, + hazardType: "Environmental", + incidentStatus: "ALL", + }, + { + id: "gMoRiHe1Z0Z", + name: "# of active verified - Animal type - Watch", + disease: null, + hazardType: "Biological: Animal", + incidentStatus: "Watch", + }, + { + id: "tKLdMcWUg9l", + name: "# of active verified - Animal type - Alert", + disease: null, + hazardType: "Biological: Animal", + incidentStatus: "Alert", + }, + { + id: "TJhGnX8E7CP", + name: "# of active verified - Animal type - Respond", + disease: null, + hazardType: "Biological: Animal", + incidentStatus: "Respond", + }, + { + id: "YfkOUZPhCY1", + name: "# of active verified - Human type - Watch", + disease: null, + hazardType: "Biological: Human", + incidentStatus: "Watch", + }, + { + id: "NzpH7Y76JBw", + name: "# of active verified - Human type - Alert", + disease: null, + hazardType: "Biological: Human", + incidentStatus: "Alert", + }, + { + id: "jWDbWYr85DP", + name: "# of active verified - Human type - Respond", + disease: null, + hazardType: "Biological: Human", + incidentStatus: "Respond", + }, + { + id: "kLtsjiyIzer", + name: "# of active verified - Human and Animal type - Watch", + disease: null, + hazardType: "Biological: Human and Animal", + incidentStatus: "Watch", + }, + { + id: "ge4Jwq2MGrF", + name: "# of active verified - Human and Animal type - Alert", + disease: null, + hazardType: "Biological: Human and Animal", + incidentStatus: "Alert", + }, + { + id: "GQ6Yg9ZN4xL", + name: "# of active verified - Human and Animal type - Respond", + disease: null, + hazardType: "Biological: Human and Animal", + incidentStatus: "Respond", + }, + { + id: null, + name: null, + disease: null, + hazardType: "Chemical (Bio-warfare)", + incidentStatus: "Watch", + }, + { + id: null, + name: null, + disease: null, + hazardType: "Chemical (Bio-warfare)", + incidentStatus: "Alert", + }, + { + id: null, + name: null, + disease: null, + hazardType: "Chemical (Bio-warfare)", + incidentStatus: "Respond", + }, + { + id: "Bu4bafAjFXN", + name: "# of active verified - Environmental type - Watch", + disease: null, + hazardType: "Environmental", + incidentStatus: "Watch", + }, + { + id: "z3EbI98pgjG", + name: "# of active verified - Environmental type - Alert", + disease: null, + hazardType: "Environmental", + incidentStatus: "Alert", + }, + { + id: "gRcZNqpKyYg", + name: "# of active verified - Environmental type - Respond", + disease: null, + hazardType: "Environmental", + incidentStatus: "Respond", + }, + ], + zebraNamespace: "zebra", + dashboardDatastoreKey: "active-verified-alerts-program-indicators", + } as MapConfig); + } +} diff --git a/src/domain/entities/MapConfig.ts b/src/domain/entities/MapConfig.ts new file mode 100644 index 00000000..e052f6d8 --- /dev/null +++ b/src/domain/entities/MapConfig.ts @@ -0,0 +1,28 @@ +export const MAP_CURRENT_APP = "ZEBRA"; + +export type MapProgramIndicator = { + id: string; + name: string; + disease: string | null; + hazardType: string | null; + incidentStatus: string | null; +}; + +export enum MapProgramIndicatorsDatastoreKey { + ActiveVerifiedAlerts = "active-verified-alerts-program-indicators", +} + +export type MapConfig = { + currentApp: "ZEBRA"; + currentPage: string; + mapId: string; + programId: string; + programName: string; + startDate: string; + timeField: string; + programIndicators: MapProgramIndicator[]; + zebraNamespace: "zebra"; + dashboardDatastoreKey: MapProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts; +}; + +export type MapKey = "dashboard" | "event_tracker"; diff --git a/src/domain/repositories/MapConfigRepository.ts b/src/domain/repositories/MapConfigRepository.ts new file mode 100644 index 00000000..85a4f76f --- /dev/null +++ b/src/domain/repositories/MapConfigRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../data/api-futures"; +import { MapConfig, MapKey } from "../entities/MapConfig"; + +export interface MapConfigRepository { + get(mapKey: MapKey): FutureData; +} diff --git a/src/domain/usecases/GetMapConfigUseCase.ts b/src/domain/usecases/GetMapConfigUseCase.ts new file mode 100644 index 00000000..4665e5a8 --- /dev/null +++ b/src/domain/usecases/GetMapConfigUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +import { MapConfig, MapKey } from "../entities/MapConfig"; +import { MapConfigRepository } from "../repositories/MapConfigRepository"; + +export class GetMapConfigUseCase { + constructor(private mapConfigRepository: MapConfigRepository) {} + + public execute(mapKey: MapKey): FutureData { + return this.mapConfigRepository.get(mapKey); + } +} diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index ba4d1938..064d911c 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -9,11 +9,14 @@ import { ThemeProvider } from "styled-components"; import OldMuiThemeProvider from "material-ui/styles/MuiThemeProvider"; import muiThemeLegacy from "../webapp/pages/app/themes/dhis2-legacy.theme"; import { muiTheme } from "../webapp/pages/app/themes/dhis2.theme"; +import { D2Api } from "../types/d2-api"; export function getTestContext() { const context: AppContextState = { currentUser: createAdminUser(), compositionRoot: getTestCompositionRoot(), + api: {} as D2Api, + isDev: true, }; return context; diff --git a/src/webapp/components/loader/LoaderContainer.tsx b/src/webapp/components/loader/LoaderContainer.tsx new file mode 100644 index 00000000..b19f9a49 --- /dev/null +++ b/src/webapp/components/loader/LoaderContainer.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { Backdrop, CircularProgress } from "@material-ui/core"; + +interface LoaderContainerProps { + loading: boolean; + children: React.ReactNode; +} + +const LoaderContainer: React.FC = ({ loading, children }) => { + return ( +
+ + + +
{children}
+
+ ); +}; + +export default LoaderContainer; diff --git a/src/webapp/components/map/Map.tsx b/src/webapp/components/map/Map.tsx new file mode 100644 index 00000000..aa575911 --- /dev/null +++ b/src/webapp/components/map/Map.tsx @@ -0,0 +1,144 @@ +import React from "react"; +import styled from "styled-components"; +import { useAppContext } from "../../contexts/app-context"; +import { FilteredMapConfig } from "../../pages/dashboard/map/useMap"; +import LoaderContainer from "../loader/LoaderContainer"; + +type MapProps = { + config: any; +}; + +function useDhis2Url(path: string) { + const { api, isDev } = useAppContext(); + return (isDev ? "/dhis2" : api.baseUrl) + path; +} + +type State = { + type: "loading" | "loaded"; +}; + +export const Map: React.FC = React.memo(props => { + const { config } = props; + + const [state, setState] = React.useState({ type: "loading" }); + + const baseUrl = useDhis2Url(`/api/apps/zebra-custom-maps-app/index.html`); + + const params = { + currentApp: config.currentApp, + currentPage: config.currentPage, + zebraNamespace: config.zebraNamespace, + dashboardDatastoreKey: config.dashboardDatastoreKey, + id: config.mapId, + orgUnits: config.orgUnits.join(","), + programIndicatorId: config.programIndicatorId, + programIndicatorName: config.programIndicatorName, + programId: config.programId, + programName: config.programName, + startDate: config.startDate, + endDate: config.endDate, + timeField: config.timeField, + }; + + const srcUrl = + baseUrl + "?" + new URLSearchParams(removeUndefinedProperties(params)).toString(); + + const iframeRef: React.RefObject = React.createRef(); + + React.useEffect(() => { + const iframe = iframeRef.current; + + if (iframe !== null) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + iframe.addEventListener("load", async () => { + await setMapStyling(iframe); + setState(prevState => ({ ...prevState, type: "loaded" })); + }); + } + }, [iframeRef]); + + const isLoading = state.type === "loading"; + + return ( + + +
+ +
+
+
+ ); +}); + +const MapEditorIFrame = styled.iframe``; + +const styles: Record = { + wrapperVisible: { width: "100%", height: "80vh" }, + wrapperHidden: { visibility: "hidden", width: "100%" }, +}; + +function removeUndefinedProperties(obj: T): Partial { + return Object.entries(obj).reduce((acc, [key, value]) => { + return value === undefined ? acc : { ...acc, [key]: value }; + }, {} as Partial); +} + +function waitforDocumentToLoad(iframeDocument: Document, selector: string) { + return new Promise(resolve => { + const check = () => { + if (iframeDocument.querySelector(selector)) { + resolve(undefined); + } else { + setTimeout(check, 1000); + } + }; + check(); + }); +} + +function waitforElementToLoad(element: HTMLElement | Document, selector: string) { + return new Promise(resolve => { + const check = () => { + if (element.querySelector(selector)) { + resolve(undefined); + } else { + setTimeout(check, 1000); + } + }; + check(); + }); +} + +async function setMapStyling(iframe: HTMLIFrameElement) { + if (!iframe.contentWindow) return; + const iframeDocument = iframe.contentWindow.document; + + await waitforDocumentToLoad(iframeDocument, "#dhis2-app-root"); + await waitforElementToLoad(iframeDocument, "header"); + await waitforElementToLoad(iframeDocument, ".dhis2-map-container-wrapper"); + + const iFrameRoot = iframeDocument.querySelector("#dhis2-app-root"); + + iframeDocument.querySelectorAll("header").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll("header").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".app-menu-container").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".app-menu-container").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".layers-toggle-container").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".layers-toggle-container").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".layers-panel-drawer").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".layers-panel-drawer").forEach(el => el.remove()); + + const mapContainerWrapper = iframeDocument.querySelector( + ".dhis2-map-container-wrapper" + ); + if (mapContainerWrapper) mapContainerWrapper.style.inset = "0px"; +} diff --git a/src/webapp/contexts/app-context.ts b/src/webapp/contexts/app-context.ts index 1ec2a73e..5ac3a0f6 100644 --- a/src/webapp/contexts/app-context.ts +++ b/src/webapp/contexts/app-context.ts @@ -1,8 +1,11 @@ import React, { useContext } from "react"; import { CompositionRoot } from "../../CompositionRoot"; import { User } from "../../domain/entities/User"; +import { D2Api } from "../../types/d2-api"; export interface AppContextState { + api: D2Api; + isDev: boolean; currentUser: User; compositionRoot: CompositionRoot; } diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index b75bc916..b759754d 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -14,14 +14,16 @@ import { muiTheme } from "./themes/dhis2.theme"; import { Router } from "../Router"; import Share from "../../components/share/Share"; import { HeaderBar } from "../../components/layout/header-bar/HeaderBar"; +import { D2Api } from "../../../types/d2-api"; import "./App.css"; export interface AppProps { compositionRoot: CompositionRoot; + api: D2Api; } function App(props: AppProps) { - const { compositionRoot } = props; + const { compositionRoot, api } = props; const [showShareButton, setShowShareButton] = useState(false); const [loading, setLoading] = useState(true); const [appContext, setAppContext] = useState(null); @@ -32,12 +34,13 @@ function App(props: AppProps) { const currentUser = await compositionRoot.users.getCurrent.execute().toPromise(); if (!currentUser) throw new Error("User not logged in"); - setAppContext({ currentUser, compositionRoot }); + const isDev = process.env.NODE_ENV === "development"; + setAppContext({ currentUser, compositionRoot, isDev, api }); setShowShareButton(isShareButtonVisible); setLoading(false); } setup(); - }, [compositionRoot]); + }, [api, compositionRoot]); if (loading) return null; diff --git a/src/webapp/pages/app/Dhis2App.tsx b/src/webapp/pages/app/Dhis2App.tsx index 655fa11f..38a92e15 100644 --- a/src/webapp/pages/app/Dhis2App.tsx +++ b/src/webapp/pages/app/Dhis2App.tsx @@ -29,12 +29,12 @@ export function Dhis2App(_props: {}) { ); } case "loaded": { - const { baseUrl, compositionRoot } = compositionRootRes.data; + const { baseUrl, compositionRoot, api } = compositionRootRes.data; const config = { baseUrl, apiVersion: 30 }; return ( - + ); } @@ -44,6 +44,7 @@ export function Dhis2App(_props: {}) { type Data = { compositionRoot: CompositionRoot; baseUrl: string; + api: D2Api; }; async function getData(): Promise { @@ -60,7 +61,7 @@ async function getData(): Promise { configI18n(userSettings); try { - return { type: "loaded", data: { baseUrl, compositionRoot } }; + return { type: "loaded", data: { baseUrl, compositionRoot, api } }; } catch (err) { return { type: "error", error: { baseUrl, error: err as Error } }; } diff --git a/src/webapp/pages/app/__tests__/App.spec.tsx b/src/webapp/pages/app/__tests__/App.spec.tsx index e39dc41b..8b62d456 100644 --- a/src/webapp/pages/app/__tests__/App.spec.tsx +++ b/src/webapp/pages/app/__tests__/App.spec.tsx @@ -13,10 +13,10 @@ describe("App", () => { }); function getView() { - const { compositionRoot } = getTestContext(); + const { compositionRoot, api } = getTestContext(); return render( - + ); } diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 19bead90..f62ce7e5 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -13,9 +13,11 @@ import { Id } from "@eyeseetea/d2-api"; import { Maybe } from "../../../utils/ts-utils"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useFilters } from "./useFilters"; +import { MapSection } from "./map/MapSection"; export const DashboardPage: React.FC = React.memo(() => { const { filters, filterOptions, setFilters } = useFilters(); + const { columns, dataPerformanceOverview, @@ -110,6 +112,9 @@ export const DashboardPage: React.FC = React.memo(() => { ))} +
+ +
; +}; + +export const MapSection: React.FC = React.memo(props => { + const { mapKey, filters } = props; + const snackbar = useSnackbar(); + + const { mapConfigState } = useMap(mapKey, filters); + + useEffect(() => { + if (mapConfigState.kind === "error") { + snackbar.error(mapConfigState.message); + } + }, [mapConfigState, snackbar]); + + if (mapConfigState.kind === "error") { + return
{mapConfigState.message}
; + } + + return ( + + + {mapConfigState.kind === "loaded" ? ( + + ) : null} + + + ); +}); + +const MapContainer = styled.div` + margin-block: 16px; + width: 100%; +`; diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts new file mode 100644 index 00000000..c3966dd0 --- /dev/null +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -0,0 +1,183 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../../../contexts/app-context"; +import { + MapKey, + MapProgramIndicator, + MapProgramIndicatorsDatastoreKey, +} from "../../../../domain/entities/MapConfig"; +import i18n from "../../../../utils/i18n"; +import { Maybe } from "../../../../utils/ts-utils"; + +type LoadingState = { + kind: "loading"; +}; + +export type FilteredMapConfig = { + currentApp: "ZEBRA"; + currentPage: string; + mapId: string; + programId: string; + programName: string; + startDate: string; + endDate?: string; + timeField: string; + zebraNamespace: "zebra"; + dashboardDatastoreKey: MapProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts; + programIndicatorId: string; + programIndicatorName: string; + orgUnits: string[]; +}; + +type LoadedState = { + kind: "loaded"; + data: FilteredMapConfig; +}; + +type ErrorState = { + kind: "error"; + message: string; +}; + +type MapConfigState = LoadingState | LoadedState | ErrorState; + +type MapState = { + mapConfigState: MapConfigState; +}; + +export function useMap(mapKey: MapKey, filters?: Record): MapState { + const { compositionRoot } = useAppContext(); + const [mapProgramIndicators, setMapProgramIndicators] = useState([]); + const [mapConfigState, setMapConfigState] = useState({ + kind: "loading", + }); + + useEffect(() => { + if (mapConfigState.kind === "loaded" && !!filters) { + const mapProgramIndicator = getFilteredMapProgramIndicator( + mapProgramIndicators, + filters + ); + if (mapProgramIndicator?.id === mapConfigState.data.programIndicatorId) { + return; + } + setMapConfigState({ kind: "loading" }); + + if (!mapProgramIndicator) { + setMapConfigState({ + kind: "error", + message: i18n.t("Map not found."), + }); + return; + } else { + setMapConfigState({ + kind: "loaded", + data: { + ...mapConfigState.data, + programIndicatorId: mapProgramIndicator.id, + programIndicatorName: mapProgramIndicator.name, + }, + }); + } + } + }, [filters, mapConfigState, mapProgramIndicators]); + + useEffect(() => { + compositionRoot.maps.getConfig.execute(mapKey).run( + config => { + setMapProgramIndicators(config.programIndicators); + + const mapProgramIndicator = getMainMapProgramIndicator(config.programIndicators); + + if (!mapProgramIndicator) { + setMapConfigState({ + kind: "error", + message: i18n.t("Map not found."), + }); + return; + } + + setMapConfigState({ + kind: "loaded", + data: { + currentApp: config.currentApp, + currentPage: config.currentPage, + mapId: config.mapId, + programId: config.programId, + programName: config.programName, + startDate: config.startDate, + timeField: config.timeField, + zebraNamespace: config.zebraNamespace, + dashboardDatastoreKey: config.dashboardDatastoreKey, + programIndicatorId: mapProgramIndicator.id, + programIndicatorName: mapProgramIndicator.name, + orgUnits: [ + "AWn3s2RqgAN", + "utIjliUdjp8", + "J7PQPWAeRUk", + "KozcEjeTyuD", + "B1u1bVtIA92", + "dbTLdTi7s8F", + "SwwuteU1Ajk", + "q5hODNmn021", + "oPLMrarKeEY", + "g1bv2xjtV0w", + ], + }, + }); + }, + error => { + console.error({ error }); + setMapConfigState({ + kind: "error", + message: i18n.t(`An error occurred while loading the map: ${error.message}`), + }); + } + ); + }, [compositionRoot.maps.getConfig, mapKey]); + + return { + mapConfigState, + }; +} + +function getMainMapProgramIndicator( + programIndicators: MapProgramIndicator[] +): Maybe { + return programIndicators.find( + indicator => + indicator.disease === "ALL" && + indicator.hazardType === "ALL" && + indicator.incidentStatus === "ALL" + ); +} + +function getFilteredMapProgramIndicator( + programIndicators: MapProgramIndicator[], + filters?: Record +): Maybe { + if (!filters || Object.values(filters).every(value => value.length === 0)) { + return getMainMapProgramIndicator(programIndicators); + } else { + const { disease, hazardType, incidentStatus } = filters; + + return programIndicators.find(indicator => { + if (disease && indicator.disease === disease[0]) { + return ( + (incidentStatus && indicator.incidentStatus === incidentStatus[0]) || + (!incidentStatus && indicator.incidentStatus === "ALL") + ); + } else if (hazardType && indicator.hazardType === hazardType[0]) { + return ( + (incidentStatus && indicator.incidentStatus === incidentStatus[0]) || + (!incidentStatus && indicator.incidentStatus === "ALL") + ); + } + return ( + ((incidentStatus && indicator.incidentStatus === incidentStatus[0]) || + (!incidentStatus && indicator.incidentStatus === "ALL")) && + indicator.disease === "ALL" && + indicator.hazardType === "ALL" + ); + }); + } +} From 1106fa9f65e32b8868471fc1018a44ae11543b39 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 10:06:35 +0200 Subject: [PATCH 03/29] Update translations --- i18n/en.pot | 10 ++++++++-- i18n/es.po | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 40e51341..3946663a 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-10T13:12:30.774Z\n" -"PO-Revision-Date: 2024-09-10T13:12:30.774Z\n" +"POT-Creation-Date: 2024-09-11T07:55:15.951Z\n" +"PO-Revision-Date: 2024-09-11T07:55:15.951Z\n" msgid "Low" msgstr "" @@ -102,12 +102,18 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" +msgid "7-1-7 performance" +msgstr "" + msgid "All public health events" msgstr "" msgid "Performance overview" msgstr "" +msgid "Map not found." +msgstr "" + msgid "Event Tracker" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 0f1e2d3c..9e08c774 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-10T13:12:30.774Z\n" +"POT-Creation-Date: 2024-09-11T07:55:15.951Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -101,12 +101,18 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" +msgid "7-1-7 performance" +msgstr "" + msgid "All public health events" msgstr "" msgid "Performance overview" msgstr "" +msgid "Map not found." +msgstr "" + msgid "Event Tracker" msgstr "" From 33c3473ae3f348bb65a0a5af260bd23918ce3aa5 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 13:55:50 +0200 Subject: [PATCH 04/29] Apply province filter to Respond, alert, watch section and change filters to single select --- src/CompositionRoot.ts | 4 + .../repositories/AnalyticsD2Repository.ts | 21 ++- src/data/repositories/OrgUnitD2Repository.ts | 12 ++ .../test/OrgUnitTestRepository.ts | 12 ++ .../repositories/AnalyticsRepository.ts | 6 +- src/domain/repositories/OrgUnitRepository.ts | 1 + .../usecases/GetDiseasesTotalUseCase.ts | 20 +-- src/domain/usecases/GetProvincesOrgUnits.ts | 11 ++ src/webapp/components/map/Map.tsx | 2 +- src/webapp/pages/dashboard/DashboardPage.tsx | 84 ++++++++---- src/webapp/pages/dashboard/map/MapSection.tsx | 27 +++- src/webapp/pages/dashboard/map/useMap.ts | 49 ++++--- .../useAlertsActiveVerifiedFilters.ts | 120 ++++++++++++++++++ .../pages/dashboard/useDiseasesTotal.ts | 29 +++-- src/webapp/pages/dashboard/useFilters.ts | 73 ----------- 15 files changed, 316 insertions(+), 155 deletions(-) create mode 100644 src/domain/usecases/GetProvincesOrgUnits.ts create mode 100644 src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts delete mode 100644 src/webapp/pages/dashboard/useFilters.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 9c8e2ffe..bc9816cb 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -28,6 +28,7 @@ import { MapConfigRepository } from "./domain/repositories/MapConfigRepository"; import { MapConfigD2Repository } from "./data/repositories/MapConfigD2Repository"; import { MapConfigTestRepository } from "./data/repositories/test/MapConfigTestRepository"; import { GetMapConfigUseCase } from "./domain/usecases/GetMapConfigUseCase"; +import { GetProvincesOrgUnits } from "./domain/usecases/GetProvincesOrgUnits"; export type CompositionRoot = ReturnType; @@ -59,6 +60,9 @@ function getCompositionRoot(repositories: Repositories) { maps: { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), }, + orgUnits: { + getProvinces: new GetProvincesOrgUnits(repositories.orgUnitRepository), + }, }; } diff --git a/src/data/repositories/AnalyticsD2Repository.ts b/src/data/repositories/AnalyticsD2Repository.ts index 549f8614..664fd697 100644 --- a/src/data/repositories/AnalyticsD2Repository.ts +++ b/src/data/repositories/AnalyticsD2Repository.ts @@ -39,10 +39,14 @@ export type ProgramIndicatorBaseAttrs = { export class AnalyticsD2Repository implements AnalyticsRepository { constructor(private api: D2Api) {} - getDiseasesTotal(filters?: Record): FutureData { + getDiseasesTotal( + allProvincesIds: string[], + singleSelectFilters?: Record, + multiSelectFilters?: Record + ): FutureData { const transformData = (data: string[][], activeVerified: typeof NB_OF_ACTIVE_VERIFIED) => { return data - .flatMap(([id, , total]) => { + .flatMap(([id, _period, _orgUnit, total]) => { const indicator = activeVerified.find(d => d.id === id); if (!indicator || !total) { return []; @@ -60,13 +64,13 @@ export class AnalyticsD2Repository implements AnalyticsRepository { if (!item) { return false; } - if (filters) { - return Object.entries(filters).every(([key, values]) => { - if (!values.length) { + if (singleSelectFilters) { + return Object.entries(singleSelectFilters).every(([key, value]) => { + if (!value) { return true; } if (item[key as keyof typeof item]) { - return values.includes(item[key as keyof typeof item] as string); + return value === (item[key as keyof typeof item] as string); } }); } @@ -80,6 +84,11 @@ export class AnalyticsD2Repository implements AnalyticsRepository { dimension: [ `dx:${NB_OF_ACTIVE_VERIFIED.map(({ id }) => id).join(";")}`, "pe:THIS_YEAR", + `ou:${ + multiSelectFilters && multiSelectFilters?.province?.length + ? multiSelectFilters.province.join(";") + : allProvincesIds.join(";") + }`, ], includeMetadataDetails: true, }) diff --git a/src/data/repositories/OrgUnitD2Repository.ts b/src/data/repositories/OrgUnitD2Repository.ts index caa61dcc..c248c39a 100644 --- a/src/data/repositories/OrgUnitD2Repository.ts +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -35,6 +35,18 @@ export class OrgUnitD2Repository implements OrgUnitRepository { }); } + getByLevel(level: number): FutureData { + return apiToFuture( + this.api.models.organisationUnits.get({ + fields: d2OrgUnitFields, + paging: false, + level: level, + }) + ).map(response => { + return this.mapD2OrgUnitsToOrgUnits(response.objects); + }); + } + private mapD2OrgUnitsToOrgUnits(d2OrgUnit: D2OrgUnit[]): OrgUnit[] { return d2OrgUnit.map( (ou): OrgUnit => ({ diff --git a/src/data/repositories/test/OrgUnitTestRepository.ts b/src/data/repositories/test/OrgUnitTestRepository.ts index 0206cc69..f8b85fb3 100644 --- a/src/data/repositories/test/OrgUnitTestRepository.ts +++ b/src/data/repositories/test/OrgUnitTestRepository.ts @@ -28,4 +28,16 @@ export class OrgUnitTestRepository implements OrgUnitRepository { }); return Future.success(orgUnits); } + + getByLevel(_level: number): FutureData { + const orgUnits: OrgUnit[] = [ + { + id: "id", + name: `Org Unit Name`, + code: `Org Unit Code`, + level: "Province", + }, + ]; + return Future.success(orgUnits); + } } diff --git a/src/domain/repositories/AnalyticsRepository.ts b/src/domain/repositories/AnalyticsRepository.ts index 497fde60..a65f5f83 100644 --- a/src/domain/repositories/AnalyticsRepository.ts +++ b/src/domain/repositories/AnalyticsRepository.ts @@ -3,5 +3,9 @@ import { ProgramIndicatorBaseAttrs } from "../../data/repositories/AnalyticsD2Re export interface AnalyticsRepository { getProgramIndicators(): FutureData; - getDiseasesTotal(filters?: Record): FutureData; + getDiseasesTotal( + allProvincesIds: string[], + singleSelectFilters?: Record, + multiSelectFilters?: Record + ): FutureData; } diff --git a/src/domain/repositories/OrgUnitRepository.ts b/src/domain/repositories/OrgUnitRepository.ts index 9758be38..1f65d086 100644 --- a/src/domain/repositories/OrgUnitRepository.ts +++ b/src/domain/repositories/OrgUnitRepository.ts @@ -5,4 +5,5 @@ import { Id } from "../entities/Ref"; export interface OrgUnitRepository { get(ids: Id[]): FutureData; getAll(): FutureData; + getByLevel(level: number): FutureData; } diff --git a/src/domain/usecases/GetDiseasesTotalUseCase.ts b/src/domain/usecases/GetDiseasesTotalUseCase.ts index f9246562..5a5ffc19 100644 --- a/src/domain/usecases/GetDiseasesTotalUseCase.ts +++ b/src/domain/usecases/GetDiseasesTotalUseCase.ts @@ -1,22 +1,26 @@ import { FutureData } from "../../data/api-futures"; -import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; -import { OptionsRepository } from "../repositories/OptionsRepository"; import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { AnalyticsRepository } from "../repositories/AnalyticsRepository"; -import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; export class GetDiseasesTotalUseCase { constructor( private options: { - diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; - optionsRepository: OptionsRepository; - teamMemberRepository: TeamMemberRepository; orgUnitRepository: OrgUnitRepository; analytics: AnalyticsRepository; } ) {} - public execute(filters?: Record): FutureData { - return this.options.analytics.getDiseasesTotal(filters); + public execute( + singleSelectFilters?: Record, + multiSelectFilters?: Record + ): FutureData { + return this.options.orgUnitRepository.getByLevel(2).flatMap(allProvinces => { + const allProvincesIds = allProvinces.map(province => province.id); + return this.options.analytics.getDiseasesTotal( + allProvincesIds, + singleSelectFilters, + multiSelectFilters + ); + }); } } diff --git a/src/domain/usecases/GetProvincesOrgUnits.ts b/src/domain/usecases/GetProvincesOrgUnits.ts new file mode 100644 index 00000000..37f74f67 --- /dev/null +++ b/src/domain/usecases/GetProvincesOrgUnits.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +import { OrgUnit } from "../entities/OrgUnit"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; + +export class GetProvincesOrgUnits { + constructor(private orgUnitRepository: OrgUnitRepository) {} + + public execute(): FutureData { + return this.orgUnitRepository.getByLevel(2); + } +} diff --git a/src/webapp/components/map/Map.tsx b/src/webapp/components/map/Map.tsx index aa575911..6843afc0 100644 --- a/src/webapp/components/map/Map.tsx +++ b/src/webapp/components/map/Map.tsx @@ -5,7 +5,7 @@ import { FilteredMapConfig } from "../../pages/dashboard/map/useMap"; import LoaderContainer from "../loader/LoaderContainer"; type MapProps = { - config: any; + config: FilteredMapConfig; }; function useDhis2Url(path: string) { diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index f62ce7e5..4069483e 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import i18n from "../../../utils/i18n"; import { Layout } from "../../components/layout/Layout"; @@ -12,11 +12,18 @@ import { MultipleSelector } from "../../components/selector/MultipleSelector"; import { Id } from "@eyeseetea/d2-api"; import { Maybe } from "../../../utils/ts-utils"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; -import { useFilters } from "./useFilters"; +import { useAlertsActiveVerifiedFilters } from "./useAlertsActiveVerifiedFilters"; import { MapSection } from "./map/MapSection"; +import { Selector } from "../../components/selector/Selector"; export const DashboardPage: React.FC = React.memo(() => { - const { filters, filterOptions, setFilters } = useFilters(); + const { + filtersConfig, + singleSelectFilters, + setSingleSelectFilters, + multiSelectFilters, + setMultiSelectFilters, + } = useAlertsActiveVerifiedFilters(); const { columns, @@ -28,7 +35,7 @@ export const DashboardPage: React.FC = React.memo(() => { editRiskAssessmentColumns, } = usePerformanceOverview(); - const { diseasesTotal } = useDiseasesTotal(filters); + const { diseasesTotal } = useDiseasesTotal(singleSelectFilters, multiSelectFilters); const { goTo } = useRoutes(); @@ -63,26 +70,52 @@ export const DashboardPage: React.FC = React.memo(() => { color: "grey", }, ]; + + const allProvinceOptionsIds = useMemo( + () => + filtersConfig + .find(filter => filter.id === "province") + ?.options.map(option => option.value), + [filtersConfig] + ); + return (
- {filterOptions.map(({ value, label, options, disabled }) => ( - - setFilters({ - ...filters, - [value]: values, - }) - } - disabled={disabled} - /> - ))} + {filtersConfig.map(({ id, label, placeholder, options, type }) => + type === "multiselector" ? ( + + setMultiSelectFilters({ + ...multiSelectFilters, + [id]: values, + }) + } + /> + ) : ( + + setSingleSelectFilters({ + ...singleSelectFilters, + [id]: value, + }) + } + /> + ) + )} {diseasesTotal && @@ -96,6 +129,14 @@ export const DashboardPage: React.FC = React.memo(() => { ))}
+
+ +
{performances && @@ -112,9 +153,6 @@ export const DashboardPage: React.FC = React.memo(() => { ))}
-
- -
; + singleSelectFilters?: Record; + multiSelectFilters?: Record; + allProvinces: Maybe; }; export const MapSection: React.FC = React.memo(props => { - const { mapKey, filters } = props; + const { mapKey, singleSelectFilters, multiSelectFilters, allProvinces } = props; const snackbar = useSnackbar(); - const { mapConfigState } = useMap(mapKey, filters); + const { mapConfigState } = useMap( + mapKey, + allProvinces, + singleSelectFilters, + multiSelectFilters + ); useEffect(() => { if (mapConfigState.kind === "error") { @@ -30,9 +38,16 @@ export const MapSection: React.FC = React.memo(props => { return ( - - {mapConfigState.kind === "loaded" ? ( - + + {mapConfigState.kind === "loaded" && allProvinces?.length !== 0 ? ( + ) : null} diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index c3966dd0..ac91ad57 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -44,7 +44,12 @@ type MapState = { mapConfigState: MapConfigState; }; -export function useMap(mapKey: MapKey, filters?: Record): MapState { +export function useMap( + mapKey: MapKey, + allOrgUnitsIds: Maybe, + singleSelectFilters?: Record, + multiSelectFilters?: Record +): MapState { const { compositionRoot } = useAppContext(); const [mapProgramIndicators, setMapProgramIndicators] = useState([]); const [mapConfigState, setMapConfigState] = useState({ @@ -52,11 +57,12 @@ export function useMap(mapKey: MapKey, filters?: Record): MapS }); useEffect(() => { - if (mapConfigState.kind === "loaded" && !!filters) { + if (mapConfigState.kind === "loaded" && (!!singleSelectFilters || !!multiSelectFilters)) { const mapProgramIndicator = getFilteredMapProgramIndicator( mapProgramIndicators, - filters + singleSelectFilters ); + if (mapProgramIndicator?.id === mapConfigState.data.programIndicatorId) { return; } @@ -79,7 +85,7 @@ export function useMap(mapKey: MapKey, filters?: Record): MapS }); } } - }, [filters, mapConfigState, mapProgramIndicators]); + }, [mapConfigState, mapProgramIndicators, multiSelectFilters, singleSelectFilters]); useEffect(() => { compositionRoot.maps.getConfig.execute(mapKey).run( @@ -96,6 +102,10 @@ export function useMap(mapKey: MapKey, filters?: Record): MapS return; } + if (!allOrgUnitsIds || allOrgUnitsIds.length === 0) { + return; + } + setMapConfigState({ kind: "loaded", data: { @@ -110,18 +120,7 @@ export function useMap(mapKey: MapKey, filters?: Record): MapS dashboardDatastoreKey: config.dashboardDatastoreKey, programIndicatorId: mapProgramIndicator.id, programIndicatorName: mapProgramIndicator.name, - orgUnits: [ - "AWn3s2RqgAN", - "utIjliUdjp8", - "J7PQPWAeRUk", - "KozcEjeTyuD", - "B1u1bVtIA92", - "dbTLdTi7s8F", - "SwwuteU1Ajk", - "q5hODNmn021", - "oPLMrarKeEY", - "g1bv2xjtV0w", - ], + orgUnits: allOrgUnitsIds, }, }); }, @@ -133,7 +132,7 @@ export function useMap(mapKey: MapKey, filters?: Record): MapS }); } ); - }, [compositionRoot.maps.getConfig, mapKey]); + }, [compositionRoot.maps.getConfig, mapKey, allOrgUnitsIds]); return { mapConfigState, @@ -153,27 +152,27 @@ function getMainMapProgramIndicator( function getFilteredMapProgramIndicator( programIndicators: MapProgramIndicator[], - filters?: Record + singleSelectFilters?: Record ): Maybe { - if (!filters || Object.values(filters).every(value => value.length === 0)) { + if (!singleSelectFilters || Object.values(singleSelectFilters).every(value => !value)) { return getMainMapProgramIndicator(programIndicators); } else { - const { disease, hazardType, incidentStatus } = filters; + const { disease, hazardType, incidentStatus } = singleSelectFilters; return programIndicators.find(indicator => { - if (disease && indicator.disease === disease[0]) { + if (disease && indicator.disease === disease) { return ( - (incidentStatus && indicator.incidentStatus === incidentStatus[0]) || + (incidentStatus && indicator.incidentStatus === incidentStatus) || (!incidentStatus && indicator.incidentStatus === "ALL") ); - } else if (hazardType && indicator.hazardType === hazardType[0]) { + } else if (hazardType && indicator.hazardType === hazardType) { return ( - (incidentStatus && indicator.incidentStatus === incidentStatus[0]) || + (incidentStatus && indicator.incidentStatus === incidentStatus) || (!incidentStatus && indicator.incidentStatus === "ALL") ); } return ( - ((incidentStatus && indicator.incidentStatus === incidentStatus[0]) || + ((incidentStatus && indicator.incidentStatus === incidentStatus) || (!incidentStatus && indicator.incidentStatus === "ALL")) && indicator.disease === "ALL" && indicator.hazardType === "ALL" diff --git a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts new file mode 100644 index 00000000..840da0a7 --- /dev/null +++ b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from "react"; +import { NB_OF_ACTIVE_VERIFIED } from "../../../data/repositories/consts/AnalyticsConstants"; +import _c from "../../../domain/entities/generic/Collection"; +import { useAppContext } from "../../contexts/app-context"; +import { OrgUnit } from "../../../domain/entities/OrgUnit"; +import { Option } from "../../components/utils/option"; + +export type FiltersConfig = { + id: string; + label: string; + placeholder: string; + type: "multiselector" | "singleselector"; + options: Option[]; +}; + +export function useAlertsActiveVerifiedFilters() { + const { compositionRoot } = useAppContext(); + + const [singleSelectFilters, setSingleSelectsFilters] = useState>({ + disease: "", + hazard: "", + incidentStatus: "", + }); + const [multiSelectFilters, setMultiSelectFilters] = useState>({ + province: [], + }); + const [provincesOptions, setProvincesOptions] = useState([]); + const [filtersConfig, setFiltersConfig] = useState([]); + + useEffect(() => { + compositionRoot.orgUnits.getProvinces.execute().run( + orgUnits => { + setProvincesOptions(buildProvinceOptions(orgUnits)); + }, + error => { + console.error({ error }); + } + ); + }, [compositionRoot.orgUnits.getProvinces]); + + const handleSetSingleSelectFilters = useCallback((newFilters: Record) => { + const cleanFilters = Object.keys(newFilters).reduce((acc, key) => { + if (key === "disease" && !!newFilters[key]) { + return { ...acc, hazard: "" }; + } else if (key === "hazard" && !!newFilters[key]) { + return { ...acc, disease: "" }; + } + return acc; + }, newFilters); + + setSingleSelectsFilters(cleanFilters); + }, []); + + // Initialize filter options based on diseasesTotal + useEffect(() => { + const buildFiltersConfig = (): FiltersConfig[] => { + const createOptions = (key: "disease" | "hazard") => + _c(NB_OF_ACTIVE_VERIFIED) + .filter(value => value.type === key) + .uniqBy(value => value.name) + .map(value => ({ + value: value.name, + label: value.name, + })) + + .value(); + + return [ + { + id: "incidentStatus", + label: "Incident Status", + placeholder: "Select Incident Status", + type: "singleselector", + options: [ + { value: "Respond", label: "Respond" }, + { value: "Alert", label: "Alert" }, + { value: "Watch", label: "Watch" }, + ], + }, + { + id: "disease", + label: "Disease", + placeholder: "Select Disease", + type: "singleselector", + options: createOptions("disease"), + }, + { + id: "hazard", + label: "Hazard Type", + placeholder: "Select Hazard Type", + type: "singleselector", + options: createOptions("hazard"), + }, + { + id: "province", + label: "Provinces", + placeholder: "Select Provinces", + type: "multiselector", + options: provincesOptions, + }, + ]; + }; + setFiltersConfig(buildFiltersConfig()); + }, [provincesOptions]); + + return { + filtersConfig, + singleSelectFilters, + setSingleSelectFilters: handleSetSingleSelectFilters, + multiSelectFilters, + setMultiSelectFilters, + }; +} + +function buildProvinceOptions(provinces: OrgUnit[]): Option[] { + return provinces.map(province => ({ + value: province.id, + label: province.name, + })); +} diff --git a/src/webapp/pages/dashboard/useDiseasesTotal.ts b/src/webapp/pages/dashboard/useDiseasesTotal.ts index 433157d3..ad3d91ba 100644 --- a/src/webapp/pages/dashboard/useDiseasesTotal.ts +++ b/src/webapp/pages/dashboard/useDiseasesTotal.ts @@ -9,7 +9,10 @@ type State = { export type Order = { name: string; direction: "asc" | "desc" }; -export function useDiseasesTotal(filters: Record): State { +export function useDiseasesTotal( + singleSelectFilters: Record, + multiSelectFilters: Record +): State { const { compositionRoot } = useAppContext(); const [diseasesTotal, setDiseasesTotal] = useState([]); @@ -17,17 +20,19 @@ export function useDiseasesTotal(filters: Record): State { useEffect(() => { setIsLoading(true); - compositionRoot.analytics.getDiseasesTotal.execute(filters).run( - diseasesTotal => { - setDiseasesTotal(diseasesTotal); - setIsLoading(false); - }, - error => { - console.error({ error }); - setIsLoading(false); - } - ); - }, [compositionRoot.analytics.getDiseasesTotal, filters]); + compositionRoot.analytics.getDiseasesTotal + .execute(singleSelectFilters, multiSelectFilters) + .run( + diseasesTotal => { + setDiseasesTotal(diseasesTotal); + setIsLoading(false); + }, + error => { + console.error({ error }); + setIsLoading(false); + } + ); + }, [compositionRoot.analytics.getDiseasesTotal, multiSelectFilters, singleSelectFilters]); return { diseasesTotal, diff --git a/src/webapp/pages/dashboard/useFilters.ts b/src/webapp/pages/dashboard/useFilters.ts deleted file mode 100644 index efeee632..00000000 --- a/src/webapp/pages/dashboard/useFilters.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { FiltersConfig } from "../../components/table/statistic-table/StatisticTable"; -import { NB_OF_ACTIVE_VERIFIED } from "../../../data/repositories/consts/AnalyticsConstants"; -import _c from "../../../domain/entities/generic/Collection"; - -export function useFilters() { - const [filters, setFilters] = useState>({}); - const [filterOptions, setFilterOptions] = useState([]); - - const buildFilterOptions = (): FiltersConfig[] => { - const createOptions = (key: "disease" | "hazard") => - _c(NB_OF_ACTIVE_VERIFIED) - .filter(value => value.type === key) - .uniqBy(value => value.name) - .map(value => ({ - value: value.name, - label: value.name, - })) - - .value(); - - return [ - { - value: "incidentStatus", - label: "Incident Status", - type: "multiselector", - options: [ - { value: "Respond", label: "Respond" }, - { value: "Alert", label: "Alert" }, - { value: "Watch", label: "Watch" }, - ], - }, - { - value: "disease", - label: "Disease", - type: "multiselector", - options: createOptions("disease"), - }, - { - value: "hazard", - label: "Hazard Type", - type: "multiselector", - options: createOptions("hazard"), - }, - ]; - }; - - const handleSetFilters = useCallback( - (newFilters: Record) => { - setFilters(newFilters); - setFilterOptions( - filterOptions.map(option => ({ - ...option, - disabled: - (newFilters.disease && newFilters.disease.length > 0 - ? option.value === "hazard" - : false) || - (newFilters.hazard && newFilters.hazard.length > 0 - ? option.value === "disease" - : false), - })) - ); - }, - [filterOptions] - ); - - // Initialize filter options based on diseasesTotal - useEffect(() => { - setFilterOptions(buildFilterOptions()); - }, []); - - return { filters, filterOptions, setFilters: handleSetFilters }; -} From b5d2b50f961a495528d603172ab706a77c28c112 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 14:05:30 +0200 Subject: [PATCH 05/29] Update translations --- i18n/en.pot | 8 ++++---- i18n/es.po | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 3946663a..a93f947b 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-11T07:55:15.951Z\n" -"PO-Revision-Date: 2024-09-11T07:55:15.951Z\n" +"POT-Creation-Date: 2024-09-11T12:04:20.691Z\n" +"PO-Revision-Date: 2024-09-11T12:04:20.691Z\n" msgid "Low" msgstr "" @@ -102,10 +102,10 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" -msgid "7-1-7 performance" +msgid "All public health events" msgstr "" -msgid "All public health events" +msgid "7-1-7 performance" msgstr "" msgid "Performance overview" diff --git a/i18n/es.po b/i18n/es.po index 9e08c774..75035fab 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-11T07:55:15.951Z\n" +"POT-Creation-Date: 2024-09-11T12:04:20.691Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -101,10 +101,10 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" -msgid "7-1-7 performance" +msgid "All public health events" msgstr "" -msgid "All public health events" +msgid "7-1-7 performance" msgstr "" msgid "Performance overview" From 8fa6b08f136824756d20f8a16ab5da4db9eb4839 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 14:24:42 +0200 Subject: [PATCH 06/29] Filter by org unit in map --- src/webapp/pages/dashboard/map/useMap.ts | 32 +++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index ac91ad57..282f1167 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -57,14 +57,30 @@ export function useMap( }); useEffect(() => { - if (mapConfigState.kind === "loaded" && (!!singleSelectFilters || !!multiSelectFilters)) { + if ( + mapConfigState.kind === "loaded" && + allOrgUnitsIds?.length && + (!!singleSelectFilters || !!multiSelectFilters) + ) { const mapProgramIndicator = getFilteredMapProgramIndicator( mapProgramIndicators, singleSelectFilters ); if (mapProgramIndicator?.id === mapConfigState.data.programIndicatorId) { - return; + if (multiSelectFilters && multiSelectFilters?.province?.length) { + setMapConfigState({ + kind: "loaded", + data: { + ...mapConfigState.data, + programIndicatorId: mapProgramIndicator.id, + programIndicatorName: mapProgramIndicator.name, + orgUnits: multiSelectFilters.province, + }, + }); + } else { + return; + } } setMapConfigState({ kind: "loading" }); @@ -81,11 +97,21 @@ export function useMap( ...mapConfigState.data, programIndicatorId: mapProgramIndicator.id, programIndicatorName: mapProgramIndicator.name, + orgUnits: + multiSelectFilters && multiSelectFilters?.province?.length + ? multiSelectFilters?.province + : allOrgUnitsIds, }, }); } } - }, [mapConfigState, mapProgramIndicators, multiSelectFilters, singleSelectFilters]); + }, [ + allOrgUnitsIds, + mapConfigState, + mapProgramIndicators, + multiSelectFilters, + singleSelectFilters, + ]); useEffect(() => { compositionRoot.maps.getConfig.execute(mapKey).run( From a0717e461f342d811004f78e727ddc346c8041d8 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 14:57:36 +0200 Subject: [PATCH 07/29] Fix url map --- src/webapp/components/map/Map.tsx | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/webapp/components/map/Map.tsx b/src/webapp/components/map/Map.tsx index 6843afc0..077512eb 100644 --- a/src/webapp/components/map/Map.tsx +++ b/src/webapp/components/map/Map.tsx @@ -8,21 +8,17 @@ type MapProps = { config: FilteredMapConfig; }; -function useDhis2Url(path: string) { - const { api, isDev } = useAppContext(); - return (isDev ? "/dhis2" : api.baseUrl) + path; -} - type State = { type: "loading" | "loaded"; }; export const Map: React.FC = React.memo(props => { const { config } = props; + const { api } = useAppContext(); const [state, setState] = React.useState({ type: "loading" }); - const baseUrl = useDhis2Url(`/api/apps/zebra-custom-maps-app/index.html`); + const baseUrl = `${api.baseUrl}/api/apps/zebra-custom-maps-app/index.html`; const params = { currentApp: config.currentApp, From 79b2fe2add837fa9820fd8757fa68c3906f107e1 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 16:03:35 +0200 Subject: [PATCH 08/29] Fix map filtering --- .../components/icon-button/IconButton.tsx | 21 +++++++-- src/webapp/components/selector/Selector.tsx | 45 +++++++++++++++++-- src/webapp/pages/dashboard/DashboardPage.tsx | 8 +--- src/webapp/pages/dashboard/map/useMap.ts | 34 ++++++++++++-- .../useAlertsActiveVerifiedFilters.ts | 25 ++++++----- 5 files changed, 105 insertions(+), 28 deletions(-) diff --git a/src/webapp/components/icon-button/IconButton.tsx b/src/webapp/components/icon-button/IconButton.tsx index b654b679..cead8769 100644 --- a/src/webapp/components/icon-button/IconButton.tsx +++ b/src/webapp/components/icon-button/IconButton.tsx @@ -5,14 +5,29 @@ import styled from "styled-components"; type IconButtonProps = { icon: React.ReactNode; disabled?: boolean; - onClick: () => void; + onClick: (event: React.MouseEvent) => void; + onMouseDown?: (event: React.MouseEvent) => void; ariaLabel?: string; + className?: string; }; export const IconButton: React.FC = React.memo( - ({ icon, disabled = false, onClick, ariaLabel = "Icon button" }) => { + ({ + icon, + disabled = false, + onClick, + ariaLabel = "Icon button", + onMouseDown, + className = "", + }) => { return ( - + {icon} ); diff --git a/src/webapp/components/selector/Selector.tsx b/src/webapp/components/selector/Selector.tsx index b6fa7ee9..722bde5c 100644 --- a/src/webapp/components/selector/Selector.tsx +++ b/src/webapp/components/selector/Selector.tsx @@ -1,10 +1,11 @@ import React, { useCallback } from "react"; import styled from "styled-components"; import { Select, InputLabel, MenuItem, FormHelperText } from "@material-ui/core"; -import { IconChevronDown24 } from "@dhis2/ui"; +import { IconChevronDown24, IconCross16 } from "@dhis2/ui"; import { getLabelFromValue } from "./utils/selectorHelper"; import { Option } from "../utils/option"; import { SearchInput } from "../search-input/SearchInput"; +import { IconButton } from "../icon-button/IconButton"; type SelectorProps = { id: string; @@ -19,6 +20,7 @@ type SelectorProps = { error?: boolean; required?: boolean; disableSearch?: boolean; + allowClear?: boolean; }; export function Selector({ @@ -34,6 +36,7 @@ export function Selector({ error = false, required = false, disableSearch = false, + allowClear = false, }: SelectorProps): JSX.Element { const [searchTerm, setSearchTerm] = React.useState(""); @@ -62,6 +65,16 @@ export function Selector({ [filteredOptions, onChange] ); + const onClearValue = useCallback( + (event: React.MouseEvent) => { + if (allowClear) { + event.stopPropagation(); + onChange("" as Value); + } + }, + [allowClear, onChange] + ); + return ( {label && ( @@ -81,9 +94,27 @@ export function Selector({ variant="outlined" IconComponent={IconChevronDown24} error={error} - renderValue={(selected: unknown) => - getLabelFromValue(selected as Value, options) || placeholder - } + renderValue={(selected: unknown) => { + const value = getLabelFromValue(selected as Value, options); + if (value) { + return ( +
+ {value} + {allowClear ? ( + } + onClick={event => onClearValue(event)} + onMouseDown={event => onClearValue(event)} + /> + ) : null} +
+ ); + } else { + return placeholder; + } + }} displayEmpty > {!disableSearch && ( @@ -160,3 +191,9 @@ const StyledSelect = styled(Select)<{ error?: boolean }>` } } `; + +const StyledIconButton = styled(IconButton)` + padding: 3px; + margin-inline-start: 4px; + background-color: ${props => props.theme.palette.common.grey200}; +`; diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 4069483e..3060a119 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -107,12 +107,8 @@ export const DashboardPage: React.FC = React.memo(() => { label={i18n.t(label)} placeholder={i18n.t(placeholder)} selected={singleSelectFilters[id] || ""} - onChange={(value: string) => - setSingleSelectFilters({ - ...singleSelectFilters, - [id]: value, - }) - } + onChange={(value: string) => setSingleSelectFilters(id, value)} + allowClear /> ) )} diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index 282f1167..40f37764 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -68,19 +68,37 @@ export function useMap( ); if (mapProgramIndicator?.id === mapConfigState.data.programIndicatorId) { - if (multiSelectFilters && multiSelectFilters?.province?.length) { + if ( + multiSelectFilters && + multiSelectFilters?.province?.length && + provincesHaveChanged(multiSelectFilters?.province, mapConfigState.data.orgUnits) + ) { + setMapConfigState({ kind: "loading" }); + setMapConfigState({ kind: "loaded", data: { ...mapConfigState.data, - programIndicatorId: mapProgramIndicator.id, - programIndicatorName: mapProgramIndicator.name, orgUnits: multiSelectFilters.province, }, }); - } else { + return; + } else if ( + !multiSelectFilters?.province?.length && + provincesHaveChanged(allOrgUnitsIds, mapConfigState.data.orgUnits) + ) { + setMapConfigState({ kind: "loading" }); + + setMapConfigState({ + kind: "loaded", + data: { + ...mapConfigState.data, + orgUnits: allOrgUnitsIds, + }, + }); return; } + return; } setMapConfigState({ kind: "loading" }); @@ -206,3 +224,11 @@ function getFilteredMapProgramIndicator( }); } } + +function provincesHaveChanged(provincesFilter: string[], currentOrgUnits: string[]): boolean { + if (provincesFilter.length !== currentOrgUnits.length) { + return true; + } + + return !provincesFilter.every((value, index) => value === currentOrgUnits[index]); +} diff --git a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts index 840da0a7..787c4912 100644 --- a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts +++ b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts @@ -38,18 +38,21 @@ export function useAlertsActiveVerifiedFilters() { ); }, [compositionRoot.orgUnits.getProvinces]); - const handleSetSingleSelectFilters = useCallback((newFilters: Record) => { - const cleanFilters = Object.keys(newFilters).reduce((acc, key) => { - if (key === "disease" && !!newFilters[key]) { - return { ...acc, hazard: "" }; - } else if (key === "hazard" && !!newFilters[key]) { - return { ...acc, disease: "" }; - } - return acc; - }, newFilters); + const handleSetSingleSelectFilters = useCallback( + (id: string, value: string) => { + const newFilters = { ...singleSelectFilters, [id]: value }; + + const cleanFilters = + id === "disease" && !!value + ? { ...newFilters, hazard: "" } + : id === "hazard" && !!value + ? { ...newFilters, disease: "" } + : newFilters; - setSingleSelectsFilters(cleanFilters); - }, []); + setSingleSelectsFilters(cleanFilters); + }, + [singleSelectFilters] + ); // Initialize filter options based on diseasesTotal useEffect(() => { From 2600482686f23df11be6adc5d077f9ede9383de3 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 16:40:25 +0200 Subject: [PATCH 09/29] Fix dashboard map indicator selector --- src/webapp/pages/dashboard/map/useMap.ts | 44 +++++++++++++++++------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index 40f37764..4e943045 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -201,26 +201,44 @@ function getFilteredMapProgramIndicator( if (!singleSelectFilters || Object.values(singleSelectFilters).every(value => !value)) { return getMainMapProgramIndicator(programIndicators); } else { - const { disease, hazardType, incidentStatus } = singleSelectFilters; + const { + disease: diseaseFilterValue, + hazard: hazardFilterValue, + incidentStatus: incidentStatusFilterValue, + } = singleSelectFilters; return programIndicators.find(indicator => { - if (disease && indicator.disease === disease) { + const isIndicatorDisease = + diseaseFilterValue && indicator.disease === diseaseFilterValue; + const isIndicatorHazardType = + hazardFilterValue && indicator.hazardType === hazardFilterValue; + const isIndicatorIncidentStatus = + incidentStatusFilterValue && indicator.incidentStatus === incidentStatusFilterValue; + + const isAllIncidentStatusIndicator = indicator.incidentStatus === "ALL"; + const isAllDiseaseIndicator = indicator.disease === "ALL"; + const isAllHazardTypeIndicator = indicator.hazardType === "ALL"; + + if (isIndicatorDisease) { return ( - (incidentStatus && indicator.incidentStatus === incidentStatus) || - (!incidentStatus && indicator.incidentStatus === "ALL") + isIndicatorIncidentStatus || + (!incidentStatusFilterValue && isAllIncidentStatusIndicator) ); - } else if (hazardType && indicator.hazardType === hazardType) { + } else if (isIndicatorHazardType) { return ( - (incidentStatus && indicator.incidentStatus === incidentStatus) || - (!incidentStatus && indicator.incidentStatus === "ALL") + isIndicatorIncidentStatus || + (!incidentStatusFilterValue && isAllIncidentStatusIndicator) + ); + } else if (isIndicatorIncidentStatus) { + return ( + (!hazardFilterValue && !diseaseFilterValue && isAllDiseaseIndicator) || + (!hazardFilterValue && !diseaseFilterValue && isAllHazardTypeIndicator) || + isIndicatorDisease || + isIndicatorHazardType ); } - return ( - ((incidentStatus && indicator.incidentStatus === incidentStatus) || - (!incidentStatus && indicator.incidentStatus === "ALL")) && - indicator.disease === "ALL" && - indicator.hazardType === "ALL" - ); + + return false; }); } } From a620d36e052351440ff2f80b7e152d8f6a896037 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 11 Sep 2024 17:55:08 +0200 Subject: [PATCH 10/29] Fix setStates using updater function --- src/webapp/pages/dashboard/map/useMap.ts | 70 +++++++++++-------- .../useAlertsActiveVerifiedFilters.ts | 24 +++---- 2 files changed, 52 insertions(+), 42 deletions(-) diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index 4e943045..5efe1abd 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -73,34 +73,42 @@ export function useMap( multiSelectFilters?.province?.length && provincesHaveChanged(multiSelectFilters?.province, mapConfigState.data.orgUnits) ) { - setMapConfigState({ kind: "loading" }); - - setMapConfigState({ - kind: "loaded", - data: { - ...mapConfigState.data, - orgUnits: multiSelectFilters.province, - }, + const provinceFilterValues = multiSelectFilters.province; + setMapConfigState(prevMapConfigState => { + if (prevMapConfigState.kind === "loaded") { + return { + kind: "loaded", + data: { + ...prevMapConfigState.data, + orgUnits: provinceFilterValues, + }, + }; + } else { + return prevMapConfigState; + } }); return; } else if ( !multiSelectFilters?.province?.length && provincesHaveChanged(allOrgUnitsIds, mapConfigState.data.orgUnits) ) { - setMapConfigState({ kind: "loading" }); - - setMapConfigState({ - kind: "loaded", - data: { - ...mapConfigState.data, - orgUnits: allOrgUnitsIds, - }, + setMapConfigState(prevMapConfigState => { + if (prevMapConfigState.kind === "loaded") { + return { + kind: "loaded", + data: { + ...prevMapConfigState.data, + orgUnits: allOrgUnitsIds, + }, + }; + } else { + return prevMapConfigState; + } }); return; } return; } - setMapConfigState({ kind: "loading" }); if (!mapProgramIndicator) { setMapConfigState({ @@ -109,17 +117,23 @@ export function useMap( }); return; } else { - setMapConfigState({ - kind: "loaded", - data: { - ...mapConfigState.data, - programIndicatorId: mapProgramIndicator.id, - programIndicatorName: mapProgramIndicator.name, - orgUnits: - multiSelectFilters && multiSelectFilters?.province?.length - ? multiSelectFilters?.province - : allOrgUnitsIds, - }, + setMapConfigState(prevMapConfigState => { + if (prevMapConfigState.kind === "loaded") { + return { + kind: "loaded", + data: { + ...prevMapConfigState.data, + programIndicatorId: mapProgramIndicator.id, + programIndicatorName: mapProgramIndicator.name, + orgUnits: + multiSelectFilters && multiSelectFilters?.province?.length + ? multiSelectFilters?.province + : allOrgUnitsIds, + }, + }; + } else { + return prevMapConfigState; + } }); } } diff --git a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts index 787c4912..ce0d112c 100644 --- a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts +++ b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts @@ -38,21 +38,17 @@ export function useAlertsActiveVerifiedFilters() { ); }, [compositionRoot.orgUnits.getProvinces]); - const handleSetSingleSelectFilters = useCallback( - (id: string, value: string) => { - const newFilters = { ...singleSelectFilters, [id]: value }; + const handleSetSingleSelectFilters = useCallback((id: string, value: string) => { + setSingleSelectsFilters(prevSingleSelectFilters => { + const newFilters = { ...prevSingleSelectFilters, [id]: value }; - const cleanFilters = - id === "disease" && !!value - ? { ...newFilters, hazard: "" } - : id === "hazard" && !!value - ? { ...newFilters, disease: "" } - : newFilters; - - setSingleSelectsFilters(cleanFilters); - }, - [singleSelectFilters] - ); + return id === "disease" && !!value + ? { ...newFilters, hazard: "" } + : id === "hazard" && !!value + ? { ...newFilters, disease: "" } + : newFilters; + }); + }, []); // Initialize filter options based on diseasesTotal useEffect(() => { From 741bb6574440805a00a8afadf75d00ce50bbd32e Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 08:21:59 +0200 Subject: [PATCH 11/29] Add map in event tracker page --- src/webapp/pages/dashboard/map/MapSection.tsx | 21 +++++-- src/webapp/pages/dashboard/map/useMap.ts | 59 +++++++++++++------ .../pages/event-tracker/EventTrackerPage.tsx | 33 +++++++++-- 3 files changed, 85 insertions(+), 28 deletions(-) diff --git a/src/webapp/pages/dashboard/map/MapSection.tsx b/src/webapp/pages/dashboard/map/MapSection.tsx index 9454eafa..aa395fce 100644 --- a/src/webapp/pages/dashboard/map/MapSection.tsx +++ b/src/webapp/pages/dashboard/map/MapSection.tsx @@ -13,18 +13,29 @@ type MapSectionProps = { singleSelectFilters?: Record; multiSelectFilters?: Record; allProvinces: Maybe; + eventDiseaseCode?: string; + eventHazardCode?: string; }; export const MapSection: React.FC = React.memo(props => { - const { mapKey, singleSelectFilters, multiSelectFilters, allProvinces } = props; + const { + mapKey, + singleSelectFilters, + multiSelectFilters, + allProvinces, + eventDiseaseCode, + eventHazardCode, + } = props; const snackbar = useSnackbar(); - const { mapConfigState } = useMap( + const { mapConfigState } = useMap({ mapKey, - allProvinces, + allOrgUnitsIds: allProvinces, singleSelectFilters, - multiSelectFilters - ); + multiSelectFilters, + eventDiseaseCode: eventDiseaseCode, + eventHazardCode: eventHazardCode, + }); useEffect(() => { if (mapConfigState.kind === "error") { diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index 5efe1abd..0653546b 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -44,12 +44,22 @@ type MapState = { mapConfigState: MapConfigState; }; -export function useMap( - mapKey: MapKey, - allOrgUnitsIds: Maybe, - singleSelectFilters?: Record, - multiSelectFilters?: Record -): MapState { +export function useMap(params: { + mapKey: MapKey; + allOrgUnitsIds: Maybe; + eventDiseaseCode?: string; + eventHazardCode?: string; + singleSelectFilters?: Record; + multiSelectFilters?: Record; +}): MapState { + const { + mapKey, + allOrgUnitsIds, + eventDiseaseCode, + eventHazardCode, + singleSelectFilters, + multiSelectFilters, + } = params; const { compositionRoot } = useAppContext(); const [mapProgramIndicators, setMapProgramIndicators] = useState([]); const [mapConfigState, setMapConfigState] = useState({ @@ -58,11 +68,12 @@ export function useMap( useEffect(() => { if ( + mapKey === "dashboard" && mapConfigState.kind === "loaded" && allOrgUnitsIds?.length && (!!singleSelectFilters || !!multiSelectFilters) ) { - const mapProgramIndicator = getFilteredMapProgramIndicator( + const mapProgramIndicator = getFilteredActiveVerifiedMapProgramIndicator( mapProgramIndicators, singleSelectFilters ); @@ -140,6 +151,7 @@ export function useMap( }, [ allOrgUnitsIds, mapConfigState, + mapKey, mapProgramIndicators, multiSelectFilters, singleSelectFilters, @@ -150,9 +162,16 @@ export function useMap( config => { setMapProgramIndicators(config.programIndicators); - const mapProgramIndicator = getMainMapProgramIndicator(config.programIndicators); + const mapProgramIndicator = + mapKey === "dashboard" + ? getMainActiveVerifiedMapProgramIndicator(config.programIndicators) + : getCasesMapProgramIndicator( + config.programIndicators, + eventDiseaseCode, + eventHazardCode + ); - if (!mapProgramIndicator) { + if (!mapProgramIndicator || !allOrgUnitsIds || allOrgUnitsIds.length === 0) { setMapConfigState({ kind: "error", message: i18n.t("Map not found."), @@ -160,10 +179,6 @@ export function useMap( return; } - if (!allOrgUnitsIds || allOrgUnitsIds.length === 0) { - return; - } - setMapConfigState({ kind: "loaded", data: { @@ -190,14 +205,14 @@ export function useMap( }); } ); - }, [compositionRoot.maps.getConfig, mapKey, allOrgUnitsIds]); + }, [compositionRoot.maps.getConfig, mapKey, allOrgUnitsIds, eventDiseaseCode, eventHazardCode]); return { mapConfigState, }; } -function getMainMapProgramIndicator( +function getMainActiveVerifiedMapProgramIndicator( programIndicators: MapProgramIndicator[] ): Maybe { return programIndicators.find( @@ -208,12 +223,22 @@ function getMainMapProgramIndicator( ); } -function getFilteredMapProgramIndicator( +function getCasesMapProgramIndicator( + programIndicators: MapProgramIndicator[], + disease: Maybe, + hazardType: Maybe +): Maybe { + return programIndicators.find( + indicator => indicator.disease === disease || indicator.hazardType === hazardType + ); +} + +function getFilteredActiveVerifiedMapProgramIndicator( programIndicators: MapProgramIndicator[], singleSelectFilters?: Record ): Maybe { if (!singleSelectFilters || Object.values(singleSelectFilters).every(value => !value)) { - return getMainMapProgramIndicator(programIndicators); + return getMainActiveVerifiedMapProgramIndicator(programIndicators); } else { const { disease: diseaseFilterValue, diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 5735fcfd..1ba0368b 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -12,6 +12,7 @@ import { getDateAsLocaleDateTimeString } from "../../../data/repositories/utils/ import { useDiseaseOutbreakEvent } from "./useDiseaseOutbreakEvent"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; +import { MapSection } from "../dashboard/map/MapSection"; // TODO: Add every section here export type VisualizationTypes = @@ -43,7 +44,8 @@ export const EventTrackerPage: React.FC = React.memo(() => { const { goTo } = useRoutes(); const { formSummary, summaryError, riskAssessmentRows, eventTrackerDetails } = useDiseaseOutbreakEvent(id); - const { changeCurrentEventTracker: changeCurrentEventTrackerId } = useCurrentEventTracker(); + const { changeCurrentEventTracker: changeCurrentEventTrackerId, getCurrentEventTracker } = + useCurrentEventTracker(); useEffect(() => { if (eventTrackerDetails) changeCurrentEventTrackerId(eventTrackerDetails); @@ -58,11 +60,30 @@ export const EventTrackerPage: React.FC = React.memo(() => { formSummary={formSummary} summaryError={summaryError} /> - +
+ +
Date: Thu, 12 Sep 2024 08:23:56 +0200 Subject: [PATCH 12/29] Add translations --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 84e50f8e..c04be308 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-11T12:04:20.691Z\n" -"PO-Revision-Date: 2024-09-11T12:04:20.691Z\n" +"POT-Creation-Date: 2024-09-12T06:22:44.023Z\n" +"PO-Revision-Date: 2024-09-12T06:22:44.023Z\n" msgid "Low" msgstr "" @@ -126,6 +126,9 @@ msgstr "" msgid "Event Tracker" msgstr "" +msgid "Districts Affected" +msgstr "" + msgid "Create Risk Assessment" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index a1026f51..8202c608 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-11T12:04:20.691Z\n" +"POT-Creation-Date: 2024-09-12T06:22:44.023Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -125,6 +125,9 @@ msgstr "" msgid "Event Tracker" msgstr "" +msgid "Districts Affected" +msgstr "" + msgid "Create Risk Assessment" msgstr "" From f77b8af32c9f46de2405d7077f4dfc957a49c31a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 08:36:52 +0200 Subject: [PATCH 13/29] Add orgUnits in app context to use them in maps --- src/CompositionRoot.ts | 2 ++ src/domain/usecases/GetAllOrgUnits.ts | 11 ++++++++ src/utils/tests.tsx | 1 + src/webapp/contexts/app-context.ts | 2 ++ src/webapp/pages/app/App.tsx | 4 +-- src/webapp/pages/dashboard/DashboardPage.tsx | 11 +------- src/webapp/pages/dashboard/map/MapSection.tsx | 27 +++++++++++-------- src/webapp/pages/dashboard/map/useMap.ts | 27 +++++++++---------- 8 files changed, 47 insertions(+), 38 deletions(-) create mode 100644 src/domain/usecases/GetAllOrgUnits.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index bc9816cb..083f0ce6 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -29,6 +29,7 @@ import { MapConfigD2Repository } from "./data/repositories/MapConfigD2Repository import { MapConfigTestRepository } from "./data/repositories/test/MapConfigTestRepository"; import { GetMapConfigUseCase } from "./domain/usecases/GetMapConfigUseCase"; import { GetProvincesOrgUnits } from "./domain/usecases/GetProvincesOrgUnits"; +import { GetAllOrgUnits } from "./domain/usecases/GetAllOrgUnits"; export type CompositionRoot = ReturnType; @@ -61,6 +62,7 @@ function getCompositionRoot(repositories: Repositories) { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), }, orgUnits: { + getAll: new GetAllOrgUnits(repositories.orgUnitRepository), getProvinces: new GetProvincesOrgUnits(repositories.orgUnitRepository), }, }; diff --git a/src/domain/usecases/GetAllOrgUnits.ts b/src/domain/usecases/GetAllOrgUnits.ts new file mode 100644 index 00000000..ff1f2965 --- /dev/null +++ b/src/domain/usecases/GetAllOrgUnits.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +import { OrgUnit } from "../entities/OrgUnit"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; + +export class GetAllOrgUnits { + constructor(private orgUnitRepository: OrgUnitRepository) {} + + public execute(): FutureData { + return this.orgUnitRepository.getAll(); + } +} diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index 064d911c..1288f73b 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -16,6 +16,7 @@ export function getTestContext() { currentUser: createAdminUser(), compositionRoot: getTestCompositionRoot(), api: {} as D2Api, + orgUnits: [], isDev: true, }; diff --git a/src/webapp/contexts/app-context.ts b/src/webapp/contexts/app-context.ts index 5ac3a0f6..13c903e8 100644 --- a/src/webapp/contexts/app-context.ts +++ b/src/webapp/contexts/app-context.ts @@ -2,12 +2,14 @@ import React, { useContext } from "react"; import { CompositionRoot } from "../../CompositionRoot"; import { User } from "../../domain/entities/User"; import { D2Api } from "../../types/d2-api"; +import { OrgUnit } from "../../domain/entities/OrgUnit"; export interface AppContextState { api: D2Api; isDev: boolean; currentUser: User; compositionRoot: CompositionRoot; + orgUnits: OrgUnit[]; } export const AppContext = React.createContext(null); diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index b759754d..c4ef93e9 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -33,9 +33,9 @@ function App(props: AppProps) { const isShareButtonVisible = appConfig.appearance.showShareButton; const currentUser = await compositionRoot.users.getCurrent.execute().toPromise(); if (!currentUser) throw new Error("User not logged in"); - + const orgUnits = await compositionRoot.orgUnits.getAll.execute().toPromise(); const isDev = process.env.NODE_ENV === "development"; - setAppContext({ currentUser, compositionRoot, isDev, api }); + setAppContext({ currentUser, compositionRoot, isDev, api, orgUnits }); setShowShareButton(isShareButtonVisible); setLoading(false); } diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 3060a119..1e474af3 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React from "react"; import i18n from "../../../utils/i18n"; import { Layout } from "../../components/layout/Layout"; @@ -71,14 +71,6 @@ export const DashboardPage: React.FC = React.memo(() => { }, ]; - const allProvinceOptionsIds = useMemo( - () => - filtersConfig - .find(filter => filter.id === "province") - ?.options.map(option => option.value), - [filtersConfig] - ); - return (
@@ -130,7 +122,6 @@ export const DashboardPage: React.FC = React.memo(() => { mapKey="dashboard" singleSelectFilters={singleSelectFilters} multiSelectFilters={multiSelectFilters} - allProvinces={allProvinceOptionsIds} />
diff --git a/src/webapp/pages/dashboard/map/MapSection.tsx b/src/webapp/pages/dashboard/map/MapSection.tsx index 9454eafa..af11e9eb 100644 --- a/src/webapp/pages/dashboard/map/MapSection.tsx +++ b/src/webapp/pages/dashboard/map/MapSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import styled from "styled-components"; import { useSnackbar } from "@eyeseetea/d2-ui-components"; @@ -6,26 +6,31 @@ import { Map } from "../../../components/map/Map"; import { useMap } from "./useMap"; import { MapKey } from "../../../../domain/entities/MapConfig"; import LoaderContainer from "../../../components/loader/LoaderContainer"; -import { Maybe } from "../../../../utils/ts-utils"; +import { useAppContext } from "../../../contexts/app-context"; type MapSectionProps = { mapKey: MapKey; singleSelectFilters?: Record; multiSelectFilters?: Record; - allProvinces: Maybe; }; export const MapSection: React.FC = React.memo(props => { - const { mapKey, singleSelectFilters, multiSelectFilters, allProvinces } = props; + const { mapKey, singleSelectFilters, multiSelectFilters } = props; + const { orgUnits } = useAppContext(); const snackbar = useSnackbar(); - const { mapConfigState } = useMap( - mapKey, - allProvinces, - singleSelectFilters, - multiSelectFilters + const allProvincesIds = useMemo( + () => orgUnits.filter(orgUnit => orgUnit.level === "Province").map(orgUnit => orgUnit.id), + [orgUnits] ); + const { mapConfigState } = useMap({ + mapKey: mapKey, + allOrgUnitsIds: allProvincesIds, + singleSelectFilters: singleSelectFilters, + multiSelectFilters: multiSelectFilters, + }); + useEffect(() => { if (mapConfigState.kind === "error") { snackbar.error(mapConfigState.message); @@ -39,9 +44,9 @@ export const MapSection: React.FC = React.memo(props => { return ( - {mapConfigState.kind === "loaded" && allProvinces?.length !== 0 ? ( + {mapConfigState.kind === "loaded" && allProvincesIds.length !== 0 ? ( , - singleSelectFilters?: Record, - multiSelectFilters?: Record -): MapState { +export function useMap(params: { + mapKey: MapKey; + allOrgUnitsIds: string[]; + singleSelectFilters?: Record; + multiSelectFilters?: Record; +}): MapState { + const { mapKey, allOrgUnitsIds, singleSelectFilters, multiSelectFilters } = params; const { compositionRoot } = useAppContext(); const [mapProgramIndicators, setMapProgramIndicators] = useState([]); const [mapConfigState, setMapConfigState] = useState({ @@ -59,7 +60,7 @@ export function useMap( useEffect(() => { if ( mapConfigState.kind === "loaded" && - allOrgUnitsIds?.length && + allOrgUnitsIds.length && (!!singleSelectFilters || !!multiSelectFilters) ) { const mapProgramIndicator = getFilteredMapProgramIndicator( @@ -71,7 +72,7 @@ export function useMap( if ( multiSelectFilters && multiSelectFilters?.province?.length && - provincesHaveChanged(multiSelectFilters?.province, mapConfigState.data.orgUnits) + orgUnitsHaveChanged(multiSelectFilters?.province, mapConfigState.data.orgUnits) ) { const provinceFilterValues = multiSelectFilters.province; setMapConfigState(prevMapConfigState => { @@ -90,7 +91,7 @@ export function useMap( return; } else if ( !multiSelectFilters?.province?.length && - provincesHaveChanged(allOrgUnitsIds, mapConfigState.data.orgUnits) + orgUnitsHaveChanged(allOrgUnitsIds, mapConfigState.data.orgUnits) ) { setMapConfigState(prevMapConfigState => { if (prevMapConfigState.kind === "loaded") { @@ -152,7 +153,7 @@ export function useMap( const mapProgramIndicator = getMainMapProgramIndicator(config.programIndicators); - if (!mapProgramIndicator) { + if (!mapProgramIndicator || allOrgUnitsIds.length === 0) { setMapConfigState({ kind: "error", message: i18n.t("Map not found."), @@ -160,10 +161,6 @@ export function useMap( return; } - if (!allOrgUnitsIds || allOrgUnitsIds.length === 0) { - return; - } - setMapConfigState({ kind: "loaded", data: { @@ -257,7 +254,7 @@ function getFilteredMapProgramIndicator( } } -function provincesHaveChanged(provincesFilter: string[], currentOrgUnits: string[]): boolean { +function orgUnitsHaveChanged(provincesFilter: string[], currentOrgUnits: string[]): boolean { if (provincesFilter.length !== currentOrgUnits.length) { return true; } From b4e7976d833f0a394e5bbc882f498aa65a6bfd51 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 11:06:53 +0200 Subject: [PATCH 14/29] Do not show event tracker map until there is disease or hazard key --- .../pages/event-tracker/EventTrackerPage.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index a65df237..a9a25496 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -13,6 +13,7 @@ import { useDiseaseOutbreakEvent } from "./useDiseaseOutbreakEvent"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; import { MapSection } from "../dashboard/map/MapSection"; +import LoaderContainer from "../../components/loader/LoaderContainer"; // TODO: Add every section here export type VisualizationTypes = @@ -66,11 +67,18 @@ export const EventTrackerPage: React.FC = React.memo(() => { hasSeparator lastUpdated={lastUpdated} > - + + +
Date: Thu, 12 Sep 2024 11:13:07 +0200 Subject: [PATCH 15/29] Add console.logs --- src/webapp/pages/dashboard/map/useMap.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index 6a33d5d7..b5c424a1 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -178,6 +178,8 @@ export function useMap(params: { }); return; } + console.log("mapProgramIndicator?.name", mapProgramIndicator?.name); + console.log("allOrgUnitsIds", allOrgUnitsIds); setMapConfigState({ kind: "loaded", From 92421745492854876ceb33b4d9fa7ea03cb771e2 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 11:26:24 +0200 Subject: [PATCH 16/29] Fix event tracker map --- src/webapp/pages/dashboard/map/useMap.ts | 8 +++++--- src/webapp/pages/event-tracker/EventTrackerPage.tsx | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index b5c424a1..21d1120b 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -124,7 +124,7 @@ export function useMap(params: { if (!mapProgramIndicator) { setMapConfigState({ kind: "error", - message: i18n.t("Map not found."), + message: i18n.t("The map with these filters could not be found."), }); return; } else { @@ -158,6 +158,10 @@ export function useMap(params: { ]); useEffect(() => { + if (mapKey === "event_tracker" && !eventDiseaseCode && !eventHazardCode) { + return; + } + compositionRoot.maps.getConfig.execute(mapKey).run( config => { setMapProgramIndicators(config.programIndicators); @@ -178,8 +182,6 @@ export function useMap(params: { }); return; } - console.log("mapProgramIndicator?.name", mapProgramIndicator?.name); - console.log("allOrgUnitsIds", allOrgUnitsIds); setMapConfigState({ kind: "loaded", diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index a9a25496..aa9d1f4a 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -69,7 +69,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { > From 89f989d6a1b66bf027e77a4273aa627ba49db430 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 11:58:12 +0200 Subject: [PATCH 17/29] Update translations --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c04be308..a6a41973 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-12T06:22:44.023Z\n" -"PO-Revision-Date: 2024-09-12T06:22:44.023Z\n" +"POT-Creation-Date: 2024-09-12T09:27:06.047Z\n" +"PO-Revision-Date: 2024-09-12T09:27:06.047Z\n" msgid "Low" msgstr "" @@ -120,6 +120,9 @@ msgstr "" msgid "Performance overview" msgstr "" +msgid "The map with these filters could not be found." +msgstr "" + msgid "Map not found." msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 8202c608..09f2440c 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T06:22:44.023Z\n" +"POT-Creation-Date: 2024-09-12T09:27:06.047Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -119,6 +119,9 @@ msgstr "" msgid "Performance overview" msgstr "" +msgid "The map with these filters could not be found." +msgstr "" + msgid "Map not found." msgstr "" From 2768fce7fdf65756f04fa0ce282569488cd21c5a Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Thu, 12 Sep 2024 11:42:38 +0200 Subject: [PATCH 18/29] add DateRangeFilter --- i18n/en.pot | 17 ++- i18n/es.po | 15 +- .../date-picker/DateRangePicker.tsx | 136 ++++++++++++++++++ .../components/text-input/TextInput.tsx | 6 + .../pages/dashboard/usePerformanceOverview.ts | 8 +- 5 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 src/webapp/components/date-picker/DateRangePicker.tsx diff --git a/i18n/en.pot b/i18n/en.pot index a6a41973..e07b5629 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-12T09:27:06.047Z\n" -"PO-Revision-Date: 2024-09-12T09:27:06.047Z\n" +"POT-Creation-Date: 2024-09-12T09:42:24.289Z\n" +"PO-Revision-Date: 2024-09-12T09:42:24.289Z\n" msgid "Low" msgstr "" @@ -69,19 +69,19 @@ msgstr "" msgid "Add new option" msgstr "" -msgid "There is an error in this field" +msgid "Cancel" msgstr "" -msgid "Indicates required" +msgid "Save" msgstr "" -msgid "Cancel" +msgid "Edit Details" msgstr "" -msgid "Save" +msgid "There is an error in this field" msgstr "" -msgid "Edit Details" +msgid "Indicates required" msgstr "" msgid "Create Event" @@ -114,6 +114,9 @@ msgstr "" msgid "All public health events" msgstr "" +msgid "Duration" +msgstr "" + msgid "7-1-7 performance" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 09f2440c..fe4d7775 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T09:27:06.047Z\n" +"POT-Creation-Date: 2024-09-12T09:42:24.289Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -68,19 +68,19 @@ msgstr "" msgid "Add new option" msgstr "" -msgid "There is an error in this field" +msgid "Cancel" msgstr "" -msgid "Indicates required" +msgid "Save" msgstr "" -msgid "Cancel" +msgid "Edit Details" msgstr "" -msgid "Save" +msgid "There is an error in this field" msgstr "" -msgid "Edit Details" +msgid "Indicates required" msgstr "" msgid "Create Event" @@ -113,6 +113,9 @@ msgstr "" msgid "All public health events" msgstr "" +msgid "Duration" +msgstr "" + msgid "7-1-7 performance" msgstr "" diff --git a/src/webapp/components/date-picker/DateRangePicker.tsx b/src/webapp/components/date-picker/DateRangePicker.tsx new file mode 100644 index 00000000..3af78f6d --- /dev/null +++ b/src/webapp/components/date-picker/DateRangePicker.tsx @@ -0,0 +1,136 @@ +import i18n from "../../../utils/i18n"; +import React, { useState, useEffect } from "react"; +import { Popover, InputAdornment } from "@material-ui/core"; +import moment from "moment"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { DatePicker } from "./DatePicker"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { IconCalendar24 } from "@dhis2/ui"; +import { TextInput } from "../text-input/TextInput"; +import { Button } from "../button/Button"; +import styled from "styled-components"; + +type DateRangePickerProps = { + value: string[]; + onChange: (dates: string[]) => void; + placeholder?: string; +}; + +export const DateRangePicker: React.FC = React.memo( + ({ value, placeholder = "", onChange }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [startDate, setStartDate] = useState( + moment(value[0]).startOf("month").toDate() + ); + const [endDate, setEndDate] = useState(moment(value[1]).toDate()); + + // Adjust startDate if endDate < startDate + useEffect(() => { + if (endDate && startDate && moment(endDate).isBefore(startDate)) { + setStartDate(endDate); + } + }, [startDate, endDate]); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleStartDateChange = (date: Date | null) => { + setStartDate(date); + }; + + const handleEndDateChange = (date: Date | null) => { + setEndDate(date); + }; + + const formatDuration = (startDate: Date | null, endDate: Date | null) => { + return `${moment(startDate).format("DD/MM/yyyy")} — ${moment(endDate).format( + "DD/MM/yyyy" + )}`; + }; + + const onCancel = () => { + setAnchorEl(null); + }; + + const onSave = () => { + if (startDate && endDate) { + setAnchorEl(null); + onChange([ + moment(startDate).format("YYYY-MM-DD"), + moment(endDate).format("YYYY-MM-DD"), + ]); + } + }; + + return ( + + {}} + onClick={handleOpen} + inputProps={{ + endAdornment: ( + + + + ), + }} + /> + + + + + + + + + + + + + + ); + } +); +const PopoverContainer = styled.div` + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +`; +const Container = styled.div` + width: 100%; + display: flex; + justify-content: space-between; +`; diff --git a/src/webapp/components/text-input/TextInput.tsx b/src/webapp/components/text-input/TextInput.tsx index 037877a4..cbd87f0e 100644 --- a/src/webapp/components/text-input/TextInput.tsx +++ b/src/webapp/components/text-input/TextInput.tsx @@ -8,11 +8,13 @@ type TextInputProps = { label?: string; value: string; onChange: (newValue: string) => void; + onClick?: (event: React.MouseEvent) => void; helperText?: string; errorText?: string; required?: boolean; disabled?: boolean; error?: boolean; + inputProps?: any; }; export const TextInput: React.FC = React.memo( @@ -21,11 +23,13 @@ export const TextInput: React.FC = React.memo( label, value, onChange, + onClick, helperText = "", errorText = "", required = false, disabled = false, error = false, + inputProps = {}, }) => { const [textFieldValue, setTextFieldValue] = useState(value || ""); const debouncedTextFieldValue = useDebounce(textFieldValue); @@ -48,11 +52,13 @@ export const TextInput: React.FC = React.memo( id={id} value={textFieldValue} onChange={event => setTextFieldValue(event.target.value)} + onClick={event => (onClick ? onClick(event) : undefined)} helperText={error && !!errorText ? errorText : helperText} error={error} disabled={disabled} required={required} variant="outlined" + InputProps={inputProps} /> ); diff --git a/src/webapp/pages/dashboard/usePerformanceOverview.ts b/src/webapp/pages/dashboard/usePerformanceOverview.ts index 9e290823..dd917349 100644 --- a/src/webapp/pages/dashboard/usePerformanceOverview.ts +++ b/src/webapp/pages/dashboard/usePerformanceOverview.ts @@ -28,13 +28,13 @@ export function usePerformanceOverview(): State { useEffect(() => { if (dataPerformanceOverview) { - setDataPerformanceOverview( - _(dataPerformanceOverview) + setDataPerformanceOverview(newDataPerformanceOverview => + _(newDataPerformanceOverview) .orderBy([ [ - (dataPerformanceOverviewdata: ProgramIndicatorBaseAttrs) => { + (dataPerformanceOverviewData: ProgramIndicatorBaseAttrs) => { const value = - dataPerformanceOverviewdata[ + dataPerformanceOverviewData[ (order?.name as keyof ProgramIndicatorBaseAttrs) || "creationDate" ]; From 4ad7e83140b7057a7272c029f40ca36160e4d34b Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 14:32:57 +0200 Subject: [PATCH 19/29] Add Duration filter in dashboard map --- .../date-picker/DateRangePicker.tsx | 4 +- src/webapp/pages/dashboard/DashboardPage.tsx | 9 ++ src/webapp/pages/dashboard/map/useMap.ts | 102 +++++++++--------- .../useAlertsActiveVerifiedFilters.ts | 1 + 4 files changed, 63 insertions(+), 53 deletions(-) diff --git a/src/webapp/components/date-picker/DateRangePicker.tsx b/src/webapp/components/date-picker/DateRangePicker.tsx index 3af78f6d..f5267df7 100644 --- a/src/webapp/components/date-picker/DateRangePicker.tsx +++ b/src/webapp/components/date-picker/DateRangePicker.tsx @@ -11,13 +11,14 @@ import { Button } from "../button/Button"; import styled from "styled-components"; type DateRangePickerProps = { + label?: string; value: string[]; onChange: (dates: string[]) => void; placeholder?: string; }; export const DateRangePicker: React.FC = React.memo( - ({ value, placeholder = "", onChange }) => { + ({ label = "", value, placeholder = "", onChange }) => { const [anchorEl, setAnchorEl] = useState(null); const [startDate, setStartDate] = useState( moment(value[0]).startOf("month").toDate() @@ -72,6 +73,7 @@ export const DateRangePicker: React.FC = React.memo( {}} onClick={handleOpen} inputProps={{ diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 1e474af3..2eb3ca3e 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -15,6 +15,7 @@ import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useAlertsActiveVerifiedFilters } from "./useAlertsActiveVerifiedFilters"; import { MapSection } from "./map/MapSection"; import { Selector } from "../../components/selector/Selector"; +import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; export const DashboardPage: React.FC = React.memo(() => { const { @@ -104,6 +105,14 @@ export const DashboardPage: React.FC = React.memo(() => { /> ) )} + + setMultiSelectFilters({ ...multiSelectFilters, duration: dates }) + } + placeholder={i18n.t("Select duration")} + label={i18n.t("Duration")} + /> {diseasesTotal && diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/pages/dashboard/map/useMap.ts index 21d1120b..6bd3d1b2 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/pages/dashboard/map/useMap.ts @@ -67,66 +67,57 @@ export function useMap(params: { }); useEffect(() => { - if ( + const isDashboardMapAndThereAreFilters = mapKey === "dashboard" && mapConfigState.kind === "loaded" && allOrgUnitsIds.length && - (!!singleSelectFilters || !!multiSelectFilters) - ) { + (!!singleSelectFilters || !!multiSelectFilters); + + if (isDashboardMapAndThereAreFilters) { const mapProgramIndicator = getFilteredActiveVerifiedMapProgramIndicator( mapProgramIndicators, singleSelectFilters ); - if (mapProgramIndicator?.id === mapConfigState.data.programIndicatorId) { - if ( - multiSelectFilters && - multiSelectFilters?.province?.length && - orgUnitsHaveChanged(multiSelectFilters?.province, mapConfigState.data.orgUnits) - ) { - const provinceFilterValues = multiSelectFilters.province; - setMapConfigState(prevMapConfigState => { - if (prevMapConfigState.kind === "loaded") { - return { - kind: "loaded", - data: { - ...prevMapConfigState.data, - orgUnits: provinceFilterValues, - }, - }; - } else { - return prevMapConfigState; - } - }); - return; - } else if ( - !multiSelectFilters?.province?.length && - orgUnitsHaveChanged(allOrgUnitsIds, mapConfigState.data.orgUnits) - ) { - setMapConfigState(prevMapConfigState => { - if (prevMapConfigState.kind === "loaded") { - return { - kind: "loaded", - data: { - ...prevMapConfigState.data, - orgUnits: allOrgUnitsIds, - }, - }; - } else { - return prevMapConfigState; - } - }); - return; - } - return; - } - if (!mapProgramIndicator) { setMapConfigState({ kind: "error", message: i18n.t("The map with these filters could not be found."), }); return; + } + + const newMapIndicator = + mapProgramIndicator?.id !== mapConfigState.data.programIndicatorId + ? mapProgramIndicator + : null; + + const newOrgUnits = + multiSelectFilters && + multiSelectFilters?.province?.length && + orgUnitsHaveChanged(multiSelectFilters?.province, mapConfigState.data.orgUnits) + ? multiSelectFilters.province + : !multiSelectFilters?.province?.length && + orgUnitsHaveChanged(allOrgUnitsIds, mapConfigState.data.orgUnits) + ? allOrgUnitsIds + : null; + + const newStartDate = + multiSelectFilters?.duration?.length && + multiSelectFilters.duration[0] && + multiSelectFilters.duration[0] !== mapConfigState.data.startDate + ? multiSelectFilters.duration[0] + : null; + + const newEndDate = + multiSelectFilters?.duration?.length && + multiSelectFilters.duration[1] && + multiSelectFilters.duration[1] !== mapConfigState.data.endDate + ? multiSelectFilters.duration[1] + : null; + + if (!newMapIndicator && !newOrgUnits && !newStartDate && !newEndDate) { + return; } else { setMapConfigState(prevMapConfigState => { if (prevMapConfigState.kind === "loaded") { @@ -134,12 +125,19 @@ export function useMap(params: { kind: "loaded", data: { ...prevMapConfigState.data, - programIndicatorId: mapProgramIndicator.id, - programIndicatorName: mapProgramIndicator.name, - orgUnits: - multiSelectFilters && multiSelectFilters?.province?.length - ? multiSelectFilters?.province - : allOrgUnitsIds, + programIndicatorId: newMapIndicator + ? newMapIndicator.id + : prevMapConfigState.data.programIndicatorId, + programIndicatorName: newMapIndicator + ? newMapIndicator.name + : prevMapConfigState.data.programIndicatorName, + orgUnits: newOrgUnits + ? newOrgUnits + : prevMapConfigState.data.orgUnits, + startDate: newStartDate + ? newStartDate + : prevMapConfigState.data.startDate, + endDate: newEndDate ? newEndDate : prevMapConfigState.data.endDate, }, }; } else { diff --git a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts index ce0d112c..36d69483 100644 --- a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts +++ b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts @@ -23,6 +23,7 @@ export function useAlertsActiveVerifiedFilters() { }); const [multiSelectFilters, setMultiSelectFilters] = useState>({ province: [], + duration: [], }); const [provincesOptions, setProvincesOptions] = useState([]); const [filtersConfig, setFiltersConfig] = useState([]); From 83c9db0bc391cef69650d15fb5243eb14ed6719a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 14:46:27 +0200 Subject: [PATCH 20/29] Add duration filter to Respond, alert, watch section cards --- .../repositories/AnalyticsD2Repository.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/data/repositories/AnalyticsD2Repository.ts b/src/data/repositories/AnalyticsD2Repository.ts index 664fd697..b7c9a657 100644 --- a/src/data/repositories/AnalyticsD2Repository.ts +++ b/src/data/repositories/AnalyticsD2Repository.ts @@ -36,6 +36,17 @@ export type ProgramIndicatorBaseAttrs = { eventDetectionDate: string; }; +const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = ("0" + (date.getMonth() + 1)).slice(-2); + const day = ("0" + date.getDate()).slice(-2); + return `${year}-${month}-${day}`; +}; + +const DEFAULT_END_DATE: string = formatDate(new Date()); + +const DEFAULT_START_DATE = "2000-01-01"; + export class AnalyticsD2Repository implements AnalyticsRepository { constructor(private api: D2Api) {} @@ -83,13 +94,20 @@ export class AnalyticsD2Repository implements AnalyticsRepository { this.api.analytics.get({ dimension: [ `dx:${NB_OF_ACTIVE_VERIFIED.map(({ id }) => id).join(";")}`, - "pe:THIS_YEAR", `ou:${ multiSelectFilters && multiSelectFilters?.province?.length ? multiSelectFilters.province.join(";") : allProvincesIds.join(";") }`, ], + startDate: + multiSelectFilters?.duration?.length && multiSelectFilters?.duration[0] + ? multiSelectFilters?.duration[0] + : DEFAULT_START_DATE, + endDate: + multiSelectFilters?.duration?.length && multiSelectFilters?.duration[1] + ? multiSelectFilters?.duration[1] + : DEFAULT_END_DATE, includeMetadataDetails: true, }) ); From 7e622f1418c8cfa45e3ae1210617409bcd6084ec Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 15:30:38 +0200 Subject: [PATCH 21/29] Add duration filter to tracker event map --- src/webapp/components/map/Map.tsx | 2 +- .../map/MapSection.tsx | 8 +-- .../dashboard => components}/map/useMap.ts | 64 +++++++++++++------ src/webapp/pages/dashboard/DashboardPage.tsx | 2 +- .../pages/event-tracker/EventTrackerPage.tsx | 32 ++++++++-- .../pages/event-tracker/useMapFilters.ts | 17 +++++ 6 files changed, 96 insertions(+), 29 deletions(-) rename src/webapp/{pages/dashboard => components}/map/MapSection.tsx (89%) rename src/webapp/{pages/dashboard => components}/map/useMap.ts (85%) create mode 100644 src/webapp/pages/event-tracker/useMapFilters.ts diff --git a/src/webapp/components/map/Map.tsx b/src/webapp/components/map/Map.tsx index 077512eb..6f3e16fe 100644 --- a/src/webapp/components/map/Map.tsx +++ b/src/webapp/components/map/Map.tsx @@ -1,7 +1,7 @@ import React from "react"; import styled from "styled-components"; import { useAppContext } from "../../contexts/app-context"; -import { FilteredMapConfig } from "../../pages/dashboard/map/useMap"; +import { FilteredMapConfig } from "./useMap"; import LoaderContainer from "../loader/LoaderContainer"; type MapProps = { diff --git a/src/webapp/pages/dashboard/map/MapSection.tsx b/src/webapp/components/map/MapSection.tsx similarity index 89% rename from src/webapp/pages/dashboard/map/MapSection.tsx rename to src/webapp/components/map/MapSection.tsx index 82255fa0..1edcdccb 100644 --- a/src/webapp/pages/dashboard/map/MapSection.tsx +++ b/src/webapp/components/map/MapSection.tsx @@ -2,11 +2,11 @@ import React, { useEffect, useMemo } from "react"; import styled from "styled-components"; import { useSnackbar } from "@eyeseetea/d2-ui-components"; -import { Map } from "../../../components/map/Map"; +import { Map } from "./Map"; import { useMap } from "./useMap"; -import { MapKey } from "../../../../domain/entities/MapConfig"; -import LoaderContainer from "../../../components/loader/LoaderContainer"; -import { useAppContext } from "../../../contexts/app-context"; +import { MapKey } from "../../../domain/entities/MapConfig"; +import LoaderContainer from "../loader/LoaderContainer"; +import { useAppContext } from "../../contexts/app-context"; type MapSectionProps = { mapKey: MapKey; diff --git a/src/webapp/pages/dashboard/map/useMap.ts b/src/webapp/components/map/useMap.ts similarity index 85% rename from src/webapp/pages/dashboard/map/useMap.ts rename to src/webapp/components/map/useMap.ts index 6bd3d1b2..c8e11306 100644 --- a/src/webapp/pages/dashboard/map/useMap.ts +++ b/src/webapp/components/map/useMap.ts @@ -1,12 +1,12 @@ import { useEffect, useState } from "react"; -import { useAppContext } from "../../../contexts/app-context"; +import { useAppContext } from "../../contexts/app-context"; import { MapKey, MapProgramIndicator, MapProgramIndicatorsDatastoreKey, -} from "../../../../domain/entities/MapConfig"; -import i18n from "../../../../utils/i18n"; -import { Maybe } from "../../../../utils/ts-utils"; +} from "../../../domain/entities/MapConfig"; +import i18n from "../../../utils/i18n"; +import { Maybe } from "../../../utils/ts-utils"; type LoadingState = { kind: "loading"; @@ -67,9 +67,24 @@ export function useMap(params: { }); useEffect(() => { + if (mapConfigState.kind !== "loaded") return; + + const newStartDate = + multiSelectFilters?.duration?.length && + multiSelectFilters.duration[0] && + multiSelectFilters.duration[0] !== mapConfigState.data.startDate + ? multiSelectFilters.duration[0] + : null; + + const newEndDate = + multiSelectFilters?.duration?.length && + multiSelectFilters.duration[1] && + multiSelectFilters.duration[1] !== mapConfigState.data.endDate + ? multiSelectFilters.duration[1] + : null; + const isDashboardMapAndThereAreFilters = mapKey === "dashboard" && - mapConfigState.kind === "loaded" && allOrgUnitsIds.length && (!!singleSelectFilters || !!multiSelectFilters); @@ -102,20 +117,6 @@ export function useMap(params: { ? allOrgUnitsIds : null; - const newStartDate = - multiSelectFilters?.duration?.length && - multiSelectFilters.duration[0] && - multiSelectFilters.duration[0] !== mapConfigState.data.startDate - ? multiSelectFilters.duration[0] - : null; - - const newEndDate = - multiSelectFilters?.duration?.length && - multiSelectFilters.duration[1] && - multiSelectFilters.duration[1] !== mapConfigState.data.endDate - ? multiSelectFilters.duration[1] - : null; - if (!newMapIndicator && !newOrgUnits && !newStartDate && !newEndDate) { return; } else { @@ -146,8 +147,33 @@ export function useMap(params: { }); } } + + if ( + mapKey === "event_tracker" && + (eventDiseaseCode || eventHazardCode) && + (newStartDate || newEndDate) + ) { + setMapConfigState(prevMapConfigState => { + if (prevMapConfigState.kind === "loaded") { + return { + kind: "loaded", + data: { + ...prevMapConfigState.data, + startDate: newStartDate + ? newStartDate + : prevMapConfigState.data.startDate, + endDate: newEndDate ? newEndDate : prevMapConfigState.data.endDate, + }, + }; + } else { + return prevMapConfigState; + } + }); + } }, [ allOrgUnitsIds, + eventDiseaseCode, + eventHazardCode, mapConfigState, mapKey, mapProgramIndicators, diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 2eb3ca3e..a8ea573f 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -13,7 +13,7 @@ import { Id } from "@eyeseetea/d2-api"; import { Maybe } from "../../../utils/ts-utils"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useAlertsActiveVerifiedFilters } from "./useAlertsActiveVerifiedFilters"; -import { MapSection } from "./map/MapSection"; +import { MapSection } from "../../components/map/MapSection"; import { Selector } from "../../components/selector/Selector"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index aa9d1f4a..ba92958f 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -1,19 +1,23 @@ import React, { useEffect } from "react"; +import styled from "styled-components"; +import { Box, Button } from "@material-ui/core"; +import { useParams } from "react-router-dom"; +import { AddCircleOutline, EditOutlined } from "@material-ui/icons"; + import i18n from "../../../utils/i18n"; import { Layout } from "../../components/layout/Layout"; -import { useParams } from "react-router-dom"; import { FormSummary } from "../../components/form/form-summary/FormSummary"; import { Visualisation } from "../../components/visualisation/Visualisation"; import { Section } from "../../components/section/Section"; -import { Box, Button } from "@material-ui/core"; -import { AddCircleOutline, EditOutlined } from "@material-ui/icons"; import { BasicTable, TableColumn } from "../../components/table/BasicTable"; import { getDateAsLocaleDateTimeString } from "../../../data/repositories/utils/DateTimeHelper"; import { useDiseaseOutbreakEvent } from "./useDiseaseOutbreakEvent"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; -import { MapSection } from "../dashboard/map/MapSection"; +import { MapSection } from "../../components/map/MapSection"; import LoaderContainer from "../../components/loader/LoaderContainer"; +import { useMapFilters } from "./useMapFilters"; +import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; // TODO: Add every section here export type VisualizationTypes = @@ -48,6 +52,8 @@ export const EventTrackerPage: React.FC = React.memo(() => { const { changeCurrentEventTracker: changeCurrentEventTrackerId, getCurrentEventTracker } = useCurrentEventTracker(); + const { multiSelectFilters, setMultiSelectFilters } = useMapFilters(); + useEffect(() => { if (eventTrackerDetails) changeCurrentEventTrackerId(eventTrackerDetails); }, [changeCurrentEventTrackerId, eventTrackerDetails, id]); @@ -67,6 +73,19 @@ export const EventTrackerPage: React.FC = React.memo(() => { hasSeparator lastUpdated={lastUpdated} > + + + setMultiSelectFilters({ + ...multiSelectFilters, + duration: dates, + }) + } + placeholder={i18n.t("Select duration")} + label={i18n.t("Duration")} + /> + { mapKey="event_tracker" eventDiseaseCode={getCurrentEventTracker()?.suspectedDiseaseCode} eventHazardCode={getCurrentEventTracker()?.hazardType} + multiSelectFilters={multiSelectFilters} />
@@ -138,3 +158,7 @@ export const EventTrackerPage: React.FC = React.memo(() => {
); }); + +const DurationFilterContainer = styled.div` + max-width: 250px; +`; diff --git a/src/webapp/pages/event-tracker/useMapFilters.ts b/src/webapp/pages/event-tracker/useMapFilters.ts new file mode 100644 index 00000000..c8b97574 --- /dev/null +++ b/src/webapp/pages/event-tracker/useMapFilters.ts @@ -0,0 +1,17 @@ +import { Dispatch, SetStateAction, useState } from "react"; + +export type MapFiltersState = { + multiSelectFilters: Record; + setMultiSelectFilters: Dispatch>>; +}; + +export function useMapFilters(): MapFiltersState { + const [multiSelectFilters, setMultiSelectFilters] = useState>({ + duration: [], + }); + + return { + multiSelectFilters, + setMultiSelectFilters, + }; +} From 5ffaefb453011177796ef513a9affe17622b460a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 12 Sep 2024 15:36:22 +0200 Subject: [PATCH 22/29] Add translations --- i18n/en.pot | 27 +++++++++++++++------------ i18n/es.po | 25 ++++++++++++++----------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index e07b5629..bb5664b2 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-12T09:42:24.289Z\n" -"PO-Revision-Date: 2024-09-12T09:42:24.289Z\n" +"POT-Creation-Date: 2024-09-12T13:31:21.959Z\n" +"PO-Revision-Date: 2024-09-12T13:31:21.959Z\n" msgid "Low" msgstr "" @@ -75,18 +75,24 @@ msgstr "" msgid "Save" msgstr "" -msgid "Edit Details" -msgstr "" - msgid "There is an error in this field" msgstr "" msgid "Indicates required" msgstr "" +msgid "Edit Details" +msgstr "" + msgid "Create Event" msgstr "" +msgid "The map with these filters could not be found." +msgstr "" + +msgid "Map not found." +msgstr "" + msgid "Close" msgstr "" @@ -111,22 +117,19 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" -msgid "All public health events" +msgid "Select duration" msgstr "" msgid "Duration" msgstr "" -msgid "7-1-7 performance" -msgstr "" - -msgid "Performance overview" +msgid "All public health events" msgstr "" -msgid "The map with these filters could not be found." +msgid "7-1-7 performance" msgstr "" -msgid "Map not found." +msgid "Performance overview" msgstr "" msgid "Event Tracker" diff --git a/i18n/es.po b/i18n/es.po index fe4d7775..e498246e 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T09:42:24.289Z\n" +"POT-Creation-Date: 2024-09-12T13:31:21.959Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -74,18 +74,24 @@ msgstr "" msgid "Save" msgstr "" -msgid "Edit Details" -msgstr "" - msgid "There is an error in this field" msgstr "" msgid "Indicates required" msgstr "" +msgid "Edit Details" +msgstr "" + msgid "Create Event" msgstr "" +msgid "The map with these filters could not be found." +msgstr "" + +msgid "Map not found." +msgstr "" + msgid "Close" msgstr "" @@ -110,22 +116,19 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" -msgid "All public health events" +msgid "Select duration" msgstr "" msgid "Duration" msgstr "" -msgid "7-1-7 performance" -msgstr "" - -msgid "Performance overview" +msgid "All public health events" msgstr "" -msgid "The map with these filters could not be found." +msgid "7-1-7 performance" msgstr "" -msgid "Map not found." +msgid "Performance overview" msgstr "" msgid "Event Tracker" From f7020363276021945889109938d6fe1d63de6502 Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Thu, 12 Sep 2024 16:06:55 +0200 Subject: [PATCH 23/29] fix DateRangePicker input value and style --- .../date-picker/DateRangePicker.tsx | 110 ++++++++++++------ .../components/text-input/TextInput.tsx | 6 - 2 files changed, 75 insertions(+), 41 deletions(-) diff --git a/src/webapp/components/date-picker/DateRangePicker.tsx b/src/webapp/components/date-picker/DateRangePicker.tsx index f5267df7..253b6745 100644 --- a/src/webapp/components/date-picker/DateRangePicker.tsx +++ b/src/webapp/components/date-picker/DateRangePicker.tsx @@ -1,12 +1,11 @@ import i18n from "../../../utils/i18n"; -import React, { useState, useEffect } from "react"; -import { Popover, InputAdornment } from "@material-ui/core"; +import React, { useState, useEffect, useMemo } from "react"; +import { Popover, InputAdornment, TextField, InputLabel } from "@material-ui/core"; import moment from "moment"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { DatePicker } from "./DatePicker"; import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; import { IconCalendar24 } from "@dhis2/ui"; -import { TextInput } from "../text-input/TextInput"; import { Button } from "../button/Button"; import styled from "styled-components"; @@ -20,10 +19,16 @@ type DateRangePickerProps = { export const DateRangePicker: React.FC = React.memo( ({ label = "", value, placeholder = "", onChange }) => { const [anchorEl, setAnchorEl] = useState(null); - const [startDate, setStartDate] = useState( - moment(value[0]).startOf("month").toDate() - ); - const [endDate, setEndDate] = useState(moment(value[1]).toDate()); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const id = "date-range-picker"; + + useEffect(() => { + if (!value || value.length !== 2) { + setStartDate(moment().startOf("month").toDate()); + setEndDate(moment().toDate()); + } + }, [value]); // Adjust startDate if endDate < startDate useEffect(() => { @@ -40,21 +45,18 @@ export const DateRangePicker: React.FC = React.memo( setAnchorEl(null); }; - const handleStartDateChange = (date: Date | null) => { - setStartDate(date); - }; - - const handleEndDateChange = (date: Date | null) => { - setEndDate(date); - }; + const formatDurationValue = useMemo(() => { + if (!value || value.length !== 2) { + return placeholder; + } - const formatDuration = (startDate: Date | null, endDate: Date | null) => { return `${moment(startDate).format("DD/MM/yyyy")} — ${moment(endDate).format( "DD/MM/yyyy" )}`; - }; + }, [startDate, endDate, placeholder, value]); - const onCancel = () => { + const onReset = () => { + onChange([]); setAnchorEl(null); }; @@ -70,20 +72,22 @@ export const DateRangePicker: React.FC = React.memo( return ( - {}} - onClick={handleOpen} - inputProps={{ - endAdornment: ( - - - - ), - }} - /> + + {label && } + + + + ), + }} + /> + = React.memo( id="start-date" label="Start Date" value={startDate} - onChange={handleStartDateChange} + onChange={date => setStartDate(date)} /> setEndDate(date)} /> - @@ -125,14 +129,50 @@ export const DateRangePicker: React.FC = React.memo( ); } ); + const PopoverContainer = styled.div` padding: 1rem; display: flex; flex-direction: column; gap: 1rem; `; + const Container = styled.div` width: 100%; display: flex; justify-content: space-between; `; + +const TextFieldContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const Label = styled(InputLabel)` + display: inline-block; + font-weight: 700; + font-size: 0.875rem; + color: ${props => props.theme.palette.text.primary}; + margin-block-end: 8px; + + &.required::after { + content: "*"; + color: ${props => props.theme.palette.common.red}; + margin-inline-start: 4px; + } +`; + +const StyledTextField = styled(TextField)` + height: 40px; + .MuiOutlinedInput-root { + height: 40px; + } + .MuiFormHelperText-root { + color: ${props => props.theme.palette.common.grey700}; + } + .MuiInputBase-input { + padding-inline: 12px; + padding-block: 10px; + } +`; diff --git a/src/webapp/components/text-input/TextInput.tsx b/src/webapp/components/text-input/TextInput.tsx index cbd87f0e..037877a4 100644 --- a/src/webapp/components/text-input/TextInput.tsx +++ b/src/webapp/components/text-input/TextInput.tsx @@ -8,13 +8,11 @@ type TextInputProps = { label?: string; value: string; onChange: (newValue: string) => void; - onClick?: (event: React.MouseEvent) => void; helperText?: string; errorText?: string; required?: boolean; disabled?: boolean; error?: boolean; - inputProps?: any; }; export const TextInput: React.FC = React.memo( @@ -23,13 +21,11 @@ export const TextInput: React.FC = React.memo( label, value, onChange, - onClick, helperText = "", errorText = "", required = false, disabled = false, error = false, - inputProps = {}, }) => { const [textFieldValue, setTextFieldValue] = useState(value || ""); const debouncedTextFieldValue = useDebounce(textFieldValue); @@ -52,13 +48,11 @@ export const TextInput: React.FC = React.memo( id={id} value={textFieldValue} onChange={event => setTextFieldValue(event.target.value)} - onClick={event => (onClick ? onClick(event) : undefined)} helperText={error && !!errorText ? errorText : helperText} error={error} disabled={disabled} required={required} variant="outlined" - InputProps={inputProps} /> ); From 15a5e312d42ae7b9cb111da2370ce520e3180f4f Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Thu, 12 Sep 2024 17:50:24 +0200 Subject: [PATCH 24/29] update translationo --- i18n/en.pot | 9 ++++++--- i18n/es.po | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index bb5664b2..256955bb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-12T13:31:21.959Z\n" -"PO-Revision-Date: 2024-09-12T13:31:21.959Z\n" +"POT-Creation-Date: 2024-09-12T14:31:25.066Z\n" +"PO-Revision-Date: 2024-09-12T14:31:25.066Z\n" msgid "Low" msgstr "" @@ -69,7 +69,7 @@ msgstr "" msgid "Add new option" msgstr "" -msgid "Cancel" +msgid "Reset" msgstr "" msgid "Save" @@ -81,6 +81,9 @@ msgstr "" msgid "Indicates required" msgstr "" +msgid "Cancel" +msgstr "" + msgid "Edit Details" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index e498246e..05887331 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T13:31:21.959Z\n" +"POT-Creation-Date: 2024-09-12T14:31:25.066Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -68,7 +68,7 @@ msgstr "" msgid "Add new option" msgstr "" -msgid "Cancel" +msgid "Reset" msgstr "" msgid "Save" @@ -80,6 +80,9 @@ msgstr "" msgid "Indicates required" msgstr "" +msgid "Cancel" +msgstr "" + msgid "Edit Details" msgstr "" From 5da0424921d81e03357614ed51c7d20d4d6c57a1 Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Mon, 16 Sep 2024 12:21:23 +0200 Subject: [PATCH 25/29] fix row `_period` not used --- src/data/repositories/AnalyticsD2Repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/repositories/AnalyticsD2Repository.ts b/src/data/repositories/AnalyticsD2Repository.ts index b7c9a657..6732d487 100644 --- a/src/data/repositories/AnalyticsD2Repository.ts +++ b/src/data/repositories/AnalyticsD2Repository.ts @@ -57,7 +57,7 @@ export class AnalyticsD2Repository implements AnalyticsRepository { ): FutureData { const transformData = (data: string[][], activeVerified: typeof NB_OF_ACTIVE_VERIFIED) => { return data - .flatMap(([id, _period, _orgUnit, total]) => { + .flatMap(([id, _orgUnit, total]) => { const indicator = activeVerified.find(d => d.id === id); if (!indicator || !total) { return []; From 4273bd70ffe8a9565b137bd6e3c0a1039a681e52 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Tue, 1 Oct 2024 15:50:38 +0200 Subject: [PATCH 26/29] Merge feature/performace-overview-table-link-indicators and solve conflicts --- .nvmrc | 2 +- i18n/en.pot | 7 +- i18n/es.po | 2 +- package.json | 6 +- src/CompositionRoot.ts | 36 +- src/data/repositories/AlertD2Repository.ts | 88 +++-- .../AlertSyncDataStoreRepository.ts | 153 ++++++++ .../repositories/AnalyticsD2Repository.ts | 259 -------------- .../DiseaseOutbreakEventD2Repository.ts | 8 +- .../repositories/NotificationD2Repository.ts | 17 + src/data/repositories/OptionsD2Repository.ts | 6 +- .../PerformanceOverviewD2Repository.ts | 261 ++++++++++++++ .../repositories/TeamMemberD2Repository.ts | 20 ++ .../repositories/consts/AlertConstants.ts | 6 + .../consts/DiseaseOutbreakConstants.ts | 32 +- ...nts.ts => PerformanceOverviewConstants.ts} | 52 ++- .../test/AlertSyncDataStoreTestRepository.ts | 12 + .../repositories/test/AlertTestRepository.ts | 5 +- .../DiseaseOutbreakEventTestRepository.ts | 11 +- .../test/OptionsTestRepository.ts | 4 + ...s => PerformanceOverviewTestRepository.ts} | 8 +- .../test/TeamMemberTestRepository.ts | 14 + .../repositories/utils/AlertOutbreakMapper.ts | 76 ++++ src/data/repositories/utils/DateTimeHelper.ts | 6 +- .../utils/DiseaseOutbreakMapper.ts | 3 +- src/data/repositories/utils/MetadataHelper.ts | 36 +- .../repositories/utils/NotificationMapper.ts | 22 ++ .../utils/getAllTrackedEntities.ts | 3 + src/domain/entities/alert/Alert.ts | 10 + src/domain/entities/alert/AlertData.ts | 30 ++ .../DiseaseOutbreakEvent.ts | 5 +- .../DiseaseOutbreakEventWithOptions.ts | 27 ++ .../PerformanceOverviewMetrics.ts | 69 ++++ src/domain/repositories/AlertRepository.ts | 7 +- .../repositories/AlertSyncRepository.ts | 19 + .../repositories/AnalyticsRepository.ts | 11 - .../repositories/NotificationRepository.ts | 20 ++ src/domain/repositories/OptionsRepository.ts | 1 + .../PerformanceOverviewRepository.ts | 17 + .../repositories/TeamMemberRepository.ts | 1 + ...GetAllPerformanceOverviewMetricsUseCase.ts | 23 ++ .../GetAllProgramIndicatorsUseCase.ts | 22 -- .../GetDiseaseOutbreakWithOptionsUseCase.ts | 93 +++++ .../usecases/GetDiseasesTotalUseCase.ts | 12 +- .../MapDiseaseOutbreakToAlertsUseCase.ts | 46 ++- .../usecases/NotifyWatchStaffUseCase.ts | 37 ++ .../MapDiseaseOutbreakToAlertsUseCase.spec.ts | 6 +- src/scripts/common.ts | 17 + src/scripts/mapDiseaseOutbreakToAlerts.ts | 335 ++++++++++++++++++ src/webapp/components/form/FormLayout.tsx | 9 +- .../form/form-summary/FormSummary.tsx | 17 +- .../form/form-summary/useFormSummary.ts | 93 +++++ .../table/statistic-table/StatisticTable.tsx | 33 +- src/webapp/pages/dashboard/DashboardPage.tsx | 80 +---- .../useAlertsActiveVerifiedFilters.ts | 20 +- .../{useDiseasesTotal.ts => useCardCounts.ts} | 21 +- .../pages/dashboard/usePerformanceOverview.ts | 101 +++--- .../event-tracker/useDiseaseOutbreakEvent.ts | 2 + .../mapFormStateToDiseaseOutbreakEventData.ts | 1 + .../form-page/mapFormStateToEntityData.ts | 5 +- yarn.lock | 13 +- 61 files changed, 1821 insertions(+), 537 deletions(-) create mode 100644 src/data/repositories/AlertSyncDataStoreRepository.ts delete mode 100644 src/data/repositories/AnalyticsD2Repository.ts create mode 100644 src/data/repositories/NotificationD2Repository.ts create mode 100644 src/data/repositories/PerformanceOverviewD2Repository.ts create mode 100644 src/data/repositories/consts/AlertConstants.ts rename src/data/repositories/consts/{AnalyticsConstants.ts => PerformanceOverviewConstants.ts} (86%) create mode 100644 src/data/repositories/test/AlertSyncDataStoreTestRepository.ts rename src/data/repositories/test/{ProgramIndicatorsTestRepository.ts => PerformanceOverviewTestRepository.ts} (92%) create mode 100644 src/data/repositories/utils/AlertOutbreakMapper.ts create mode 100644 src/data/repositories/utils/NotificationMapper.ts create mode 100644 src/domain/entities/alert/Alert.ts create mode 100644 src/domain/entities/alert/AlertData.ts create mode 100644 src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts create mode 100644 src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts create mode 100644 src/domain/repositories/AlertSyncRepository.ts delete mode 100644 src/domain/repositories/AnalyticsRepository.ts create mode 100644 src/domain/repositories/NotificationRepository.ts create mode 100644 src/domain/repositories/PerformanceOverviewRepository.ts create mode 100644 src/domain/usecases/GetAllPerformanceOverviewMetricsUseCase.ts delete mode 100644 src/domain/usecases/GetAllProgramIndicatorsUseCase.ts create mode 100644 src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts create mode 100644 src/domain/usecases/NotifyWatchStaffUseCase.ts create mode 100644 src/scripts/common.ts create mode 100644 src/scripts/mapDiseaseOutbreakToAlerts.ts create mode 100644 src/webapp/components/form/form-summary/useFormSummary.ts rename src/webapp/pages/dashboard/{useDiseasesTotal.ts => useCardCounts.ts} (66%) diff --git a/.nvmrc b/.nvmrc index 07c7cf30..eb800ed4 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.14.2 +v18.19.0 diff --git a/i18n/en.pot b/i18n/en.pot index 256955bb..63e5dd37 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-12T14:31:25.066Z\n" -"PO-Revision-Date: 2024-09-12T14:31:25.066Z\n" +"POT-Creation-Date: 2024-09-30T07:39:32.843Z\n" +"PO-Revision-Date: 2024-09-30T07:39:32.843Z\n" msgid "Low" msgstr "" @@ -87,6 +87,9 @@ msgstr "" msgid "Edit Details" msgstr "" +msgid "Notes" +msgstr "" + msgid "Create Event" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 05887331..e9452c28 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T14:31:25.066Z\n" +"POT-Creation-Date: 2024-09-12T14:10:04.460Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" diff --git a/package.json b/package.json index 220f02a2..32c84ab4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dhis2/ui": "6.12.0", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", - "@eyeseetea/d2-api": "1.16.0-beta.9", + "@eyeseetea/d2-api": "1.16.0-beta.12", "@eyeseetea/d2-ui-components": "v2.9.0-beta.2", "@eyeseetea/feedback-component": "0.0.3", "@material-ui/core": "4.12.4", @@ -29,6 +29,7 @@ "classnames": "2.3.1", "d2": "31.10.2", "d2-manifest": "1.0.0", + "dotenv": "^16.4.5", "font-awesome": "4.7.0", "moment": "^2.30.1", "purify-ts": "1.2.0", @@ -111,7 +112,8 @@ "localize": "yarn update-po && d2-i18n-generate -n dhis2-skeleton-app -p ./i18n/ -o ./src/locales/", "update-po": "yarn extract-pot && find i18n/ -name '*.po' -exec msgmerge --backup=off -U {} i18n/en.pot \\;", "prepare": "husky install", - "script-example": "npx ts-node src/scripts/example.ts" + "script-example": "npx ts-node src/scripts/example.ts", + "script-map-outbreak-to-alerts": "npx ts-node -r dotenv/config src/scripts/mapDiseaseOutbreakToAlerts.ts" }, "manifest.webapp": { "name": "ZEBRA", diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 549489c3..29931856 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -26,17 +26,21 @@ import { SaveEntityUseCase } from "./domain/usecases/SaveEntityUseCase"; import { RiskAssessmentRepository } from "./domain/repositories/RiskAssessmentRepository"; import { RiskAssessmentD2Repository } from "./data/repositories/RiskAssessmentD2Repository"; import { RiskAssessmentTestRepository } from "./data/repositories/test/RiskAssessmentTestRepository"; -import { AnalyticsRepository } from "./domain/repositories/AnalyticsRepository"; -import { GetAllProgramIndicatorsUseCase } from "./domain/usecases/GetAllProgramIndicatorsUseCase"; -import { AnalyticsD2Repository } from "./data/repositories/AnalyticsD2Repository"; -import { ProgramIndicatorsTestRepository } from "./data/repositories/test/ProgramIndicatorsTestRepository"; -import { GetDiseasesTotalUseCase } from "./domain/usecases/GetDiseasesTotalUseCase"; import { MapConfigRepository } from "./domain/repositories/MapConfigRepository"; import { MapConfigD2Repository } from "./data/repositories/MapConfigD2Repository"; import { MapConfigTestRepository } from "./data/repositories/test/MapConfigTestRepository"; import { GetMapConfigUseCase } from "./domain/usecases/GetMapConfigUseCase"; import { GetProvincesOrgUnits } from "./domain/usecases/GetProvincesOrgUnits"; import { GetAllOrgUnits } from "./domain/usecases/GetAllOrgUnits"; +import { PerformanceOverviewRepository } from "./domain/repositories/PerformanceOverviewRepository"; +import { GetAllPerformanceOverviewMetricsUseCase } from "./domain/usecases/GetAllPerformanceOverviewMetricsUseCase"; +import { PerformanceOverviewD2Repository } from "./data/repositories/PerformanceOverviewD2Repository"; +import { PerformanceOverviewTestRepository } from "./data/repositories/test/PerformanceOverviewTestRepository"; +import { GetTotalCardCountsUseCase } from "./domain/usecases/GetDiseasesTotalUseCase"; +import { AlertSyncDataStoreRepository } from "./data/repositories/AlertSyncDataStoreRepository"; +import { AlertSyncDataStoreTestRepository } from "./data/repositories/test/AlertSyncDataStoreTestRepository"; +import { AlertSyncRepository } from "./domain/repositories/AlertSyncRepository"; +import { DataStoreClient } from "./data/DataStoreClient"; export type CompositionRoot = ReturnType; @@ -44,12 +48,13 @@ type Repositories = { usersRepository: UserRepository; diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; alertRepository: AlertRepository; + alertSyncRepository: AlertSyncRepository; optionsRepository: OptionsRepository; teamMemberRepository: TeamMemberRepository; orgUnitRepository: OrgUnitRepository; riskAssessmentRepository: RiskAssessmentRepository; - analytics: AnalyticsRepository; mapConfigRepository: MapConfigRepository; + performanceOverviewRepository: PerformanceOverviewRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -66,12 +71,16 @@ function getCompositionRoot(repositories: Repositories) { get: new GetDiseaseOutbreakByIdUseCase(repositories), getAll: new GetAllDiseaseOutbreaksUseCase(repositories.diseaseOutbreakEventRepository), mapDiseaseOutbreakEventToAlerts: new MapDiseaseOutbreakToAlertsUseCase( - repositories.alertRepository + repositories.alertRepository, + repositories.alertSyncRepository, + repositories.optionsRepository ), }, - analytics: { - getProgramIndicators: new GetAllProgramIndicatorsUseCase(repositories), - getDiseasesTotal: new GetDiseasesTotalUseCase(repositories), + performanceOverview: { + getPerformanceOverviewMetrics: new GetAllPerformanceOverviewMetricsUseCase( + repositories + ), + getTotalCardCounts: new GetTotalCardCountsUseCase(repositories), }, maps: { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), @@ -84,16 +93,18 @@ function getCompositionRoot(repositories: Repositories) { } export function getWebappCompositionRoot(api: D2Api) { + const dataStoreClient = new DataStoreClient(api); const repositories: Repositories = { usersRepository: new UserD2Repository(api), diseaseOutbreakEventRepository: new DiseaseOutbreakEventD2Repository(api), alertRepository: new AlertD2Repository(api), + alertSyncRepository: new AlertSyncDataStoreRepository(api), optionsRepository: new OptionsD2Repository(api), teamMemberRepository: new TeamMemberD2Repository(api), orgUnitRepository: new OrgUnitD2Repository(api), riskAssessmentRepository: new RiskAssessmentD2Repository(api), - analytics: new AnalyticsD2Repository(api), mapConfigRepository: new MapConfigD2Repository(api), + performanceOverviewRepository: new PerformanceOverviewD2Repository(api, dataStoreClient), }; return getCompositionRoot(repositories); @@ -104,12 +115,13 @@ export function getTestCompositionRoot() { usersRepository: new UserTestRepository(), diseaseOutbreakEventRepository: new DiseaseOutbreakEventTestRepository(), alertRepository: new AlertTestRepository(), + alertSyncRepository: new AlertSyncDataStoreTestRepository(), optionsRepository: new OptionsTestRepository(), teamMemberRepository: new TeamMemberTestRepository(), orgUnitRepository: new OrgUnitTestRepository(), riskAssessmentRepository: new RiskAssessmentTestRepository(), - analytics: new ProgramIndicatorsTestRepository(), mapConfigRepository: new MapConfigTestRepository(), + performanceOverviewRepository: new PerformanceOverviewTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/AlertD2Repository.ts b/src/data/repositories/AlertD2Repository.ts index 218c9986..7a850837 100644 --- a/src/data/repositories/AlertD2Repository.ts +++ b/src/data/repositories/AlertD2Repository.ts @@ -18,8 +18,9 @@ import { } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { Maybe } from "../../utils/ts-utils"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Alert } from "../../domain/entities/alert/Alert"; -type Filter = { +export type Filter = { id: Id; value: Maybe; }; @@ -27,46 +28,64 @@ type Filter = { export class AlertD2Repository implements AlertRepository { constructor(private api: D2Api) {} - updateAlerts(alertOptions: AlertOptions): FutureData { + updateAlerts(alertOptions: AlertOptions): FutureData { const { dataSource, eventId, hazardTypeCode, incidentStatus, suspectedDiseaseCode } = alertOptions; const filter = this.getAlertFilter(dataSource, suspectedDiseaseCode, hazardTypeCode); - return this.getTrackedEntitiesByTEACode(filter).flatMap(response => { - const alertsToMap = response.map(trackedEntity => ({ - ...trackedEntity, - attributes: [ - { - attribute: RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID, - value: eventId, - }, - { - attribute: RTSL_ZEBRA_ALERTS_NATIONAL_INCIDENT_STATUS_TEA_ID, - value: incidentStatus, - }, - ], + return this.getTrackedEntitiesByTEACode({ + program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + ouMode: "DESCENDANTS", + filter: filter, + }).flatMap(alertTrackedEntities => { + const alertsToMap: Alert[] = alertTrackedEntities.map(trackedEntity => ({ + id: trackedEntity.trackedEntity || "", + district: trackedEntity.orgUnit || "", })); - if (alertsToMap.length === 0) return Future.success(undefined); + const alertsToPost: D2TrackerTrackedEntity[] = alertTrackedEntities.map( + trackedEntity => ({ + trackedEntity: trackedEntity.trackedEntity, + trackedEntityType: trackedEntity.trackedEntityType, + orgUnit: trackedEntity.orgUnit, + attributes: [ + { + attribute: RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID, + value: eventId, + }, + { + attribute: RTSL_ZEBRA_ALERTS_NATIONAL_INCIDENT_STATUS_TEA_ID, + value: incidentStatus, + }, + ], + }) + ); + + if (alertsToMap.length === 0) return Future.success([]); return apiToFuture( this.api.tracker.post( { importStrategy: "UPDATE" }, - { trackedEntities: alertsToMap } + { trackedEntities: alertsToPost } ) ).flatMap(saveResponse => { if (saveResponse.status === "ERROR") return Future.error( new Error("Error mapping disease outbreak event id to alert") ); - else return Future.success(undefined); + else return Future.success(alertsToMap); }); }); } - private async getTrackedEntitiesByTEACodeAsync( - filter: Filter - ): Promise { + private async getTrackedEntitiesByTEACodeAsync(options: { + program: Id; + orgUnit: Id; + ouMode: "SELECTED" | "DESCENDANTS"; + filter?: Filter; + }): Promise { + const { program, orgUnit, ouMode, filter } = options; const d2TrackerTrackedEntities: D2TrackerTrackedEntity[] = []; const pageSize = 250; @@ -77,9 +96,9 @@ export class AlertD2Repository implements AlertRepository { do { result = await this.api.tracker.trackedEntities .get({ - program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, - orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, - ouMode: "DESCENDANTS", + program: program, + orgUnit: orgUnit, + ouMode: ouMode, totalPages: true, page: page, pageSize: pageSize, @@ -88,8 +107,18 @@ export class AlertD2Repository implements AlertRepository { orgUnit: true, trackedEntity: true, trackedEntityType: true, + enrollments: { + events: { + createdAt: true, + dataValues: { + dataElement: true, + value: true, + }, + event: true, + }, + }, }, - filter: `${filter.id}:eq:${filter.value}`, + filter: filter ? `${filter.id}:eq:${filter.value}` : undefined, }) .getData(); @@ -103,8 +132,13 @@ export class AlertD2Repository implements AlertRepository { } } - private getTrackedEntitiesByTEACode(filter: Filter): FutureData { - return Future.fromPromise(this.getTrackedEntitiesByTEACodeAsync(filter)); + getTrackedEntitiesByTEACode(options: { + program: Id; + orgUnit: Id; + ouMode: "SELECTED" | "DESCENDANTS"; + filter?: Filter; + }): FutureData { + return Future.fromPromise(this.getTrackedEntitiesByTEACodeAsync(options)); } private getAlertFilter( diff --git a/src/data/repositories/AlertSyncDataStoreRepository.ts b/src/data/repositories/AlertSyncDataStoreRepository.ts new file mode 100644 index 00000000..cf054678 --- /dev/null +++ b/src/data/repositories/AlertSyncDataStoreRepository.ts @@ -0,0 +1,153 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { DataStoreClient } from "../DataStoreClient"; +import { Future } from "../../domain/entities/generic/Future"; +import { + AlertSyncOptions, + AlertSyncRepository, +} from "../../domain/repositories/AlertSyncRepository"; +import { apiToFuture, FutureData } from "../api-futures"; +import { getOutbreakKey, getAlertValueFromMap } from "./utils/AlertOutbreakMapper"; +import { Maybe } from "../../utils/ts-utils"; +import { DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; +import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; +import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { RTSL_ZEBRA_ALERTS_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; +import { assertOrError } from "./utils/AssertOrError"; +import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { Alert, VerificationStatus } from "../../domain/entities/alert/Alert"; + +export class AlertSyncDataStoreRepository implements AlertSyncRepository { + private dataStoreClient: DataStoreClient; + + constructor(private api: D2Api) { + this.dataStoreClient = new DataStoreClient(api); + } + + saveAlertSyncData(options: AlertSyncOptions): FutureData { + const { + alert, + dataSource, + hazardTypeCode, + suspectedDiseaseCode, + hazardTypes, + suspectedDiseases, + } = options; + + return this.getAlertTrackedEntity(alert).flatMap(alertTrackedEntity => { + const verificationStatus = getAlertValueFromMap( + "verificationStatus", + alertTrackedEntity + ); + + if (verificationStatus === VerificationStatus.RTSL_ZEB_AL_OS_VERIFICATION_VERIFIED) { + const outbreakKey = getOutbreakKey({ + dataSource: dataSource, + outbreakValue: suspectedDiseaseCode || hazardTypeCode, + hazardTypes: hazardTypes, + suspectedDiseases: suspectedDiseases, + }); + + if (!outbreakKey) return Future.error(new Error(`No outbreak key found`)); + + const synchronizationData = this.buildSynchronizationData( + options, + alertTrackedEntity, + outbreakKey + ); + + return this.getAlertObject(outbreakKey).flatMap(outbreakData => { + const syncData: AlertSynchronizationData = !outbreakData + ? synchronizationData + : { + ...outbreakData, + lastSyncTime: new Date().toISOString(), + alerts: [...outbreakData.alerts, ...synchronizationData.alerts], + }; + + return this.saveAlertObject(outbreakKey, syncData); + }); + } + + return Future.success(undefined); + }); + } + + public getAlertTrackedEntity(alert: Alert): FutureData { + return apiToFuture( + this.api.tracker.trackedEntities.get({ + program: RTSL_ZEBRA_ALERTS_PROGRAM_ID, + orgUnit: alert.district, + trackedEntity: alert.id, + ouMode: "SELECTED", + fields: { + trackedEntity: true, + attributes: true, + enrollments: true, + }, + }) + ).flatMap(response => assertOrError(response.instances[0], "Tracked entity")); + } + + private getAlertObject(outbreakKey: string): FutureData> { + return this.dataStoreClient.getObject(outbreakKey); + } + + private saveAlertObject( + outbreakKey: string, + syncData: AlertSynchronizationData + ): FutureData { + return this.dataStoreClient.saveObject(outbreakKey, syncData); + } + + private buildSynchronizationData( + options: AlertSyncOptions, + trackedEntity: D2TrackerTrackedEntity, + outbreakKey: string + ): AlertSynchronizationData { + const { alert, nationalDiseaseOutbreakEventId, dataSource } = options; + const outbreakType = + dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS ? "disease" : "hazard"; + + const alerts = + trackedEntity.enrollments?.flatMap(enrollment => + enrollment.events?.map(event => { + const dataValues = event.dataValues; + + return { + type: outbreakType, + alertId: event.event, + eventDate: event.createdAt, + orgUnit: alert.district, + suspectedCases: getDataValueFromMap("Suspected Cases", dataValues), + probableCases: getDataValueFromMap("Probable Cases", dataValues), + confirmedCases: getDataValueFromMap("Confirmed Cases", dataValues), + deaths: getDataValueFromMap("Deaths", dataValues), + }; + }) + ) ?? []; + + return { + lastSyncTime: new Date().toISOString(), + type: outbreakType, + nationalDiseaseOutbreakEventId: nationalDiseaseOutbreakEventId, + [outbreakType]: outbreakKey, + alerts: alerts, + }; + } +} + +function getDataValueFromMap( + key: keyof typeof dataElementIds, + dataValues: Maybe +): string { + if (!dataValues) return ""; + + return dataValues.find(dataValue => dataValue.dataElement === dataElementIds[key])?.value ?? ""; +} + +const dataElementIds = { + "Suspected Cases": "d4B5pN7ZTEu", + "Probable Cases": "bUMlIfyJEYK", + "Confirmed Cases": "ApKJDLI5nHP", + Deaths: "Sfl82Bx0ZNz", +} as const; diff --git a/src/data/repositories/AnalyticsD2Repository.ts b/src/data/repositories/AnalyticsD2Repository.ts deleted file mode 100644 index 6732d487..00000000 --- a/src/data/repositories/AnalyticsD2Repository.ts +++ /dev/null @@ -1,259 +0,0 @@ -import { Maybe } from "./../../utils/ts-utils"; -import { AnalyticsResponse, D2Api } from "../../types/d2-api"; -import { AnalyticsRepository } from "../../domain/repositories/AnalyticsRepository"; -import { apiToFuture, FutureData } from "../api-futures"; -import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; -import _ from "../../domain/entities/generic/Collection"; -import { Future } from "../../domain/entities/generic/Future"; -import { - IndicatorsId, - NB_OF_ACTIVE_VERIFIED, - NB_OF_CASES, - NB_OF_DEATHS, -} from "./consts/AnalyticsConstants"; -import moment from "moment"; - -export type ProgramIndicatorBaseAttrs = { - id: string; - event: string; - province: string; - duration: string; - manager: string; - cases: string; - deaths: string; - era1: string; - era2: string; - era3: string; - era4: string; - era5: string; - era6: string; - era7: string; - detect7d: string; - notify1d: string; - respond7d: string; - creationDate: string; - suspectedDisease: string; - eventDetectionDate: string; -}; - -const formatDate = (date: Date): string => { - const year = date.getFullYear(); - const month = ("0" + (date.getMonth() + 1)).slice(-2); - const day = ("0" + date.getDate()).slice(-2); - return `${year}-${month}-${day}`; -}; - -const DEFAULT_END_DATE: string = formatDate(new Date()); - -const DEFAULT_START_DATE = "2000-01-01"; - -export class AnalyticsD2Repository implements AnalyticsRepository { - constructor(private api: D2Api) {} - - getDiseasesTotal( - allProvincesIds: string[], - singleSelectFilters?: Record, - multiSelectFilters?: Record - ): FutureData { - const transformData = (data: string[][], activeVerified: typeof NB_OF_ACTIVE_VERIFIED) => { - return data - .flatMap(([id, _orgUnit, total]) => { - const indicator = activeVerified.find(d => d.id === id); - if (!indicator || !total) { - return []; - } - return [ - { - id, - [indicator.type]: indicator.name, - incidentStatus: indicator.incidentStatus, - total: parseFloat(total), - }, - ]; - }) - .filter(item => { - if (!item) { - return false; - } - if (singleSelectFilters) { - return Object.entries(singleSelectFilters).every(([key, value]) => { - if (!value) { - return true; - } - if (item[key as keyof typeof item]) { - return value === (item[key as keyof typeof item] as string); - } - }); - } - return true; - }); - }; - - const fetchCasesAnalytics = (): FutureData => - apiToFuture( - this.api.analytics.get({ - dimension: [ - `dx:${NB_OF_ACTIVE_VERIFIED.map(({ id }) => id).join(";")}`, - `ou:${ - multiSelectFilters && multiSelectFilters?.province?.length - ? multiSelectFilters.province.join(";") - : allProvincesIds.join(";") - }`, - ], - startDate: - multiSelectFilters?.duration?.length && multiSelectFilters?.duration[0] - ? multiSelectFilters?.duration[0] - : DEFAULT_START_DATE, - endDate: - multiSelectFilters?.duration?.length && multiSelectFilters?.duration[1] - ? multiSelectFilters?.duration[1] - : DEFAULT_END_DATE, - includeMetadataDetails: true, - }) - ); - return fetchCasesAnalytics().map(res => { - const rows = transformData(res.rows, NB_OF_ACTIVE_VERIFIED) || []; - - return Object.values( - rows.reduce((acc, { disease, hazard, total }) => { - const name = (disease || hazard) as string; - if (!name) { - return acc; - } - - const existingEntry = - acc[name] || (disease ? { disease, total: 0 } : { hazard, total: 0 }); - existingEntry.total += total; - // @ts-ignore - acc[name] = existingEntry; - return acc; - }, {} as Record) - ); - }); - } - - getProgramIndicators(): FutureData { - const fetchEnrollmentsQuery = (): FutureData => - apiToFuture( - this.api.get( - `/analytics/enrollments/query/${RTSL_ZEBRA_PROGRAM_ID}`, - { - enrollmentDate: "LAST_12_MONTHS,THIS_MONTH", - dimension: [ - IndicatorsId.suspectedDisease, - IndicatorsId.event, - IndicatorsId.era1, - IndicatorsId.era2, - IndicatorsId.era3, - IndicatorsId.era4, - IndicatorsId.era5, - IndicatorsId.era6, - IndicatorsId.era7, - IndicatorsId.detect7d, - IndicatorsId.notify1d, - IndicatorsId.respond7d, - ], - } - ) - ); - - const fetchCasesAnalytics = (): FutureData => - apiToFuture( - this.api.analytics.get({ - dimension: [ - `dx:${NB_OF_CASES.map(({ id }) => id).join(";")}`, - "pe:LAST_30_DAYS", - ], - includeMetadataDetails: true, - }) - ); - - const fetchDeathsAnalytics = (): FutureData => - apiToFuture( - this.api.analytics.get({ - dimension: [ - `dx:${NB_OF_DEATHS.map(({ id }) => id).join(";")}`, - "pe:LAST_30_DAYS", - ], - includeMetadataDetails: true, - }) - ); - - return Future.joinObj({ - indicatorsProgramFuture: fetchEnrollmentsQuery(), - nbOfCasesByDiseaseFuture: fetchCasesAnalytics(), - nbOfDeathsByDiseaseFuture: fetchDeathsAnalytics(), - }).map( - ({ - indicatorsProgramFuture, - nbOfCasesByDiseaseFuture, - nbOfDeathsByDiseaseFuture, - }: Record) => { - const cases = this.calculateTotals(nbOfCasesByDiseaseFuture, NB_OF_CASES); - const deaths = this.calculateTotals(nbOfDeathsByDiseaseFuture, NB_OF_DEATHS); - - return ( - indicatorsProgramFuture?.rows.map((row: string[]) => { - return this.mapRowToIndicator( - row, - indicatorsProgramFuture.headers, - indicatorsProgramFuture.metaData, - cases, - deaths - ); - }) || [] - ); - } - ); - } - - private calculateTotals( - response: Maybe, - indicators: typeof NB_OF_CASES | typeof NB_OF_DEATHS - ): Record { - return ( - response?.rows.reduce((acc: Record, [key, , value]: string[]) => { - const name = indicators.find(({ id }) => id === key)?.disease; - if (name && value) { - acc[name] = (acc[name] || 0) + parseFloat(value); - } - return acc; - }, {}) || {} - ); - } - - private mapRowToIndicator( - row: string[], - headers: { name: string; column: string }[], - metaData: AnalyticsResponse["metaData"], - cases: Record, - deaths: Record - ): ProgramIndicatorBaseAttrs { - return headers.reduce((acc, header, index) => { - const key = Object.keys(IndicatorsId).find( - key => IndicatorsId[key as keyof typeof IndicatorsId] === header.name - ) as Maybe; - - if (!key) return acc; - - // TODO: FIXME Fix TypeScript, do not use any - if (key === "suspectedDisease") { - acc[key] = - ( - Object.values(metaData.items).find( - (item: any) => item.code === row[index] - ) as any - )?.name || ""; - acc.cases = cases[acc.suspectedDisease]?.toString() || ""; - acc.deaths = deaths[acc.suspectedDisease]?.toString() || ""; - } else if (key === "eventDetectionDate") { - acc.duration = `${moment().diff(moment(row[index]), "days").toString()}d`; - acc[key] = moment(row[index]).format("YYYY-MM-DD"); // Keep the original date formatted - } else { - acc[key] = row[index] || ""; - } - - return acc; - }, {} as ProgramIndicatorBaseAttrs); - } -} diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index ebb477d8..d0056f75 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -36,9 +36,11 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep return Future.fromPromise( getAllTrackedEntitiesAsync(this.api, RTSL_ZEBRA_PROGRAM_ID, RTSL_ZEBRA_ORG_UNIT_ID) ).map(trackedEntities => { - return trackedEntities.map(trackedEntity => { - return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); - }); + return trackedEntities + .map(trackedEntity => { + return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); + }) + .filter(outbreak => outbreak.status === "ACTIVE"); }); } diff --git a/src/data/repositories/NotificationD2Repository.ts b/src/data/repositories/NotificationD2Repository.ts new file mode 100644 index 00000000..c63d4429 --- /dev/null +++ b/src/data/repositories/NotificationD2Repository.ts @@ -0,0 +1,17 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { + Notification, + NotificationRepository, +} from "../../domain/repositories/NotificationRepository"; +import { apiToFuture, FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; + +export class NotificationD2Repository implements NotificationRepository { + constructor(private api: D2Api) {} + + save(notification: Notification): FutureData { + return apiToFuture(this.api.messageConversations.post(notification)).flatMap(() => + Future.success(undefined) + ); + } +} diff --git a/src/data/repositories/OptionsD2Repository.ts b/src/data/repositories/OptionsD2Repository.ts index 143979b9..b8d9255b 100644 --- a/src/data/repositories/OptionsD2Repository.ts +++ b/src/data/repositories/OptionsD2Repository.ts @@ -71,8 +71,12 @@ export class OptionsD2Repository implements OptionsRepository { return this.getOptionSetByCode("RTSL_ZEB_OS_DATA_SOURCE"); } + getHazardTypesByCode(): FutureData { + return this.getOptionSetByCode("RTSL_ZEB_OS_HAZARD_TYPE"); + } + getHazardTypes(): FutureData { - return this.getOptionSetByCode("RTSL_ZEB_OS_HAZARD_TYPE").map(hazardTypes => { + return this.getHazardTypesByCode().map(hazardTypes => { return _(hazardTypes) .compactMap(hazardType => { const hazardTypeId = getHazardTypeByCode(hazardType.id); diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts new file mode 100644 index 00000000..4987a874 --- /dev/null +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -0,0 +1,261 @@ +import { Maybe } from "../../utils/ts-utils"; +import { AnalyticsResponse, D2Api } from "../../types/d2-api"; +import { PerformanceOverviewRepository } from "../../domain/repositories/PerformanceOverviewRepository"; +import { apiToFuture, FutureData } from "../api-futures"; +import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; +import _ from "../../domain/entities/generic/Collection"; +import { Future } from "../../domain/entities/generic/Future"; +import { evenTrackerCountsIndicatorMap, IndicatorsId } from "./consts/PerformanceOverviewConstants"; +import moment from "moment"; +import { + DiseaseOutbreakEventBaseAttrs, + NationalIncidentStatus, +} from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { DataStoreClient } from "../DataStoreClient"; +import { + TotalCardCounts, + HazardNames, + PerformanceOverviewMetrics, + DiseaseNames, +} from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; +import { OrgUnit } from "../../domain/entities/OrgUnit"; + +const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = ("0" + (date.getMonth() + 1)).slice(-2); + const day = ("0" + date.getDate()).slice(-2); + return `${year}-${month}-${day}`; +}; + +const DEFAULT_END_DATE: string = formatDate(new Date()); + +const DEFAULT_START_DATE = "2000-01-01"; + +export class PerformanceOverviewD2Repository implements PerformanceOverviewRepository { + constructor(private api: D2Api, private datastore: DataStoreClient) {} + + getTotalCardCounts( + allProvincesIds: string[], + singleSelectFilters?: Record, + multiSelectFilters?: Record + ): FutureData { + return apiToFuture( + this.api.analytics.get({ + dimension: [ + `dx:${evenTrackerCountsIndicatorMap.map(({ id }) => id).join(";")}`, + `ou:${ + multiSelectFilters && multiSelectFilters?.province?.length + ? multiSelectFilters.province.join(";") + : allProvincesIds.join(";") + }`, + ], + startDate: + multiSelectFilters?.duration?.length && multiSelectFilters?.duration[0] + ? multiSelectFilters?.duration[0] + : DEFAULT_START_DATE, + endDate: + multiSelectFilters?.duration?.length && multiSelectFilters?.duration[1] + ? multiSelectFilters?.duration[1] + : DEFAULT_END_DATE, + includeMetadataDetails: true, + }) + ).map(analyticsResponse => { + const totalCardCounts = + this.mapAnalyticsRowsToTotalCardCounts( + analyticsResponse.rows, + singleSelectFilters + ) || []; + + const uniqueTotalCardCounts = totalCardCounts.reduce((acc, totalCardCount) => { + const existingEntry = acc[totalCardCount.name]; + + if (existingEntry) { + existingEntry.total += totalCardCount.total; + acc[totalCardCount.name] = existingEntry; + } else { + acc[totalCardCount.name] = totalCardCount; + } + return acc; + }, {} as Record); + + return Object.values(uniqueTotalCardCounts); + }); + } + mapAnalyticsRowsToTotalCardCounts = ( + rowData: string[][], + filters?: Record + ): TotalCardCounts[] => { + const counts: TotalCardCounts[] = _( + rowData.map(([id, _orgUnit, total]) => { + const indicator = evenTrackerCountsIndicatorMap.find(d => d.id === id); + if (!indicator || !total) { + return null; + } + + if (indicator.type === "hazard") { + const hazardCount = { + id: id, + name: indicator.name, + type: indicator.type, + incidentStatus: indicator.incidentStatus, + total: parseFloat(total), + }; + return hazardCount; + } else { + const diseaseCount = { + id: id, + name: indicator.name, + type: indicator.type, + incidentStatus: indicator.incidentStatus, + total: parseFloat(total), + }; + return diseaseCount; + } + }) + ) + .compact() + .value(); + + const filteredCounts: TotalCardCounts[] = counts.filter(item => { + if (filters && Object.entries(filters).length) { + return Object.entries(filters).every(([key, value]) => { + if (!value) { + return true; + } + if (key === "incidentStatus") { + return value === item.incidentStatus; + } else if (key === "disease" || key === "hazard") { + return value === item.name; + } + }); + } + return true; + }); + return filteredCounts; + }; + + getPerformanceOverviewMetrics( + diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] + ): FutureData { + return apiToFuture( + this.api.get( + `/analytics/enrollments/query/${RTSL_ZEBRA_PROGRAM_ID}`, + { + enrollmentDate: "LAST_12_MONTHS,THIS_MONTH", + dimension: [ + IndicatorsId.suspectedDisease, + IndicatorsId.hazardType, + IndicatorsId.event, + IndicatorsId.era1, + IndicatorsId.era2, + IndicatorsId.era3, + IndicatorsId.era4, + IndicatorsId.era5, + IndicatorsId.era6, + IndicatorsId.era7, + IndicatorsId.detect7d, + IndicatorsId.notify1d, + IndicatorsId.respond7d, + ], + } + ) + ).flatMap(indicatorsProgramFuture => { + const mappedIndicators = + indicatorsProgramFuture?.rows.map((row: string[]) => + this.mapRowToBaseIndicator( + row, + indicatorsProgramFuture.headers, + indicatorsProgramFuture.metaData + ) + ) || []; + + const performanceOverviewMetrics = diseaseOutbreakEvents.map(event => { + const baseIndicator = mappedIndicators.find(indicator => indicator.id === event.id); + const key = baseIndicator?.suspectedDisease || baseIndicator?.hazardType; + + return this.getCasesAndDeathsFromDatastore(key).map(casesAndDeaths => { + const duration = `${moment() + .diff(moment(event.emerged.date), "days") + .toString()}d`; + if (!baseIndicator) { + return { + id: event.id, + event: event.name, + manager: event.incidentManagerName, + duration: duration, + nationalIncidentStatus: event.incidentStatus, + cases: casesAndDeaths.cases.toString(), + deaths: casesAndDeaths.deaths.toString(), + } as PerformanceOverviewMetrics; + } + return { + ...baseIndicator, + nationalIncidentStatus: event.incidentStatus, + manager: event.incidentManagerName, + duration: duration, + cases: casesAndDeaths.cases.toString(), + deaths: casesAndDeaths.deaths.toString(), + } as PerformanceOverviewMetrics; + }); + }); + + return Future.sequential(performanceOverviewMetrics); + }); + } + + private getCasesAndDeathsFromDatastore( + key: string | undefined + ): FutureData<{ cases: number; deaths: number }> { + if (!key) return Future.success({ cases: 0, deaths: 0 }); + return this.datastore.getObject(key).flatMap(data => { + if (!data) return Future.success({ cases: 0, deaths: 0 }); + const casesDeaths = data.alerts.reduce( + (acc, alert) => { + acc.cases += parseInt(alert.suspectedCases) || 0; + acc.deaths += parseInt(alert.deaths) || 0; + return acc; + }, + { cases: 0, deaths: 0 } + ); + + return Future.success(casesDeaths); + }); + } + + private mapRowToBaseIndicator( + row: string[], + headers: { name: string; column: string }[], + metaData: AnalyticsResponse["metaData"] + ): Partial { + return headers.reduce((acc, header, index) => { + const key = Object.keys(IndicatorsId).find( + key => IndicatorsId[key as keyof typeof IndicatorsId] === header.name + ) as Maybe; + + if (!key) return acc; + + if (key === "suspectedDisease") { + acc[key] = + (( + Object.values(metaData.items).find( + item => (item as any).code === row[index] + ) as any + )?.name as DiseaseNames) || ""; + } else if (key === "hazardType") { + acc[key] = + (( + Object.values(metaData.items).find( + item => (item as any).code === row[index] + ) as any + )?.name as HazardNames) || ""; + } else if (key === "nationalIncidentStatus") { + acc[key] = row[index] as NationalIncidentStatus; + } else { + acc[key] = row[index] as (HazardNames & OrgUnit[]) | undefined; + } + + return acc; + }, {} as Partial); + } +} diff --git a/src/data/repositories/TeamMemberD2Repository.ts b/src/data/repositories/TeamMemberD2Repository.ts index 3643670a..acc5f898 100644 --- a/src/data/repositories/TeamMemberD2Repository.ts +++ b/src/data/repositories/TeamMemberD2Repository.ts @@ -6,6 +6,8 @@ import { apiToFuture, FutureData } from "../api-futures"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; +const RTSL_ZEBRA_INCIDENTMANAGER = "UOd3K79040G"; + export class TeamMemberD2Repository implements TeamMemberRepository { constructor(private api: D2Api) {} @@ -26,6 +28,24 @@ export class TeamMemberD2Repository implements TeamMemberRepository { ); }); } + getIncidentManagers(): FutureData { + return apiToFuture( + this.api.metadata.get({ + users: { + fields: d2UserFields, + filter: { "userGroups.id": { in: [RTSL_ZEBRA_INCIDENTMANAGER] } }, + }, + }) + ) + .flatMap(response => assertOrError(response.users, `Team Members not found`)) + .flatMap(d2Users => { + if (d2Users.length === 0) return Future.error(new Error(`Team Members not found`)); + else + return Future.success( + d2Users.map(d2User => this.mapUserToTeamMember(d2User as D2UserFix)) + ); + }); + } get(id: Id): FutureData { return apiToFuture( diff --git a/src/data/repositories/consts/AlertConstants.ts b/src/data/repositories/consts/AlertConstants.ts new file mode 100644 index 00000000..fee438dd --- /dev/null +++ b/src/data/repositories/consts/AlertConstants.ts @@ -0,0 +1,6 @@ +export const alertOutbreakCodes = { + hazardType: "RTSL_ZEB_TEA_EVENT_TYPE", + suspectedDisease: "RTSL_ZEB_TEA_DISEASE", + verificationStatus: "RTSL_ZEB_TEA_VERIFICATION_STATUS", + incidentManager: "RTSL_ZEB_TEA_ ALERT_IM_NAME", +} as const; diff --git a/src/data/repositories/consts/DiseaseOutbreakConstants.ts b/src/data/repositories/consts/DiseaseOutbreakConstants.ts index 957280b7..f3c832cc 100644 --- a/src/data/repositories/consts/DiseaseOutbreakConstants.ts +++ b/src/data/repositories/consts/DiseaseOutbreakConstants.ts @@ -2,8 +2,9 @@ import { DataSource, DiseaseOutbreakEventBaseAttrs, HazardType, - IncidentStatus, + NationalIncidentStatus, } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import _c from "../../../domain/entities/generic/Collection"; import { GetValue, Maybe } from "../../../utils/ts-utils"; import { getDateAsIsoString } from "../utils/DateTimeHelper"; @@ -29,12 +30,13 @@ export const hazardTypeCodeMap: Record = { Unknown: "RTSL_ZEB_OS_HAZARD_TYPE_UNKNOWN", }; -export const incidentStatusMap: Record = { - RTSL_ZEB_OS_INCIDENT_STATUS_WATCH: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, - RTSL_ZEB_OS_INCIDENT_STATUS_ALERT: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_ALERT, - RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND, - RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED, - RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED, +export const incidentStatusMap: Record = { + RTSL_ZEB_OS_INCIDENT_STATUS_WATCH: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + RTSL_ZEB_OS_INCIDENT_STATUS_ALERT: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_ALERT, + RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND, + RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED, + RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED: + NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED, }; export const dataSourceMap: Record = { @@ -68,6 +70,7 @@ export const diseaseOutbreakCodes = { initiatePublicHealthCounterMeasuresDate: "RTSL_ZEB_TEA_SPECIFY_DATE3", initiateRiskCommunicationNA: "RTSL_ZEB_TEA_APPROPRIATE_RISK_COMMUNICATION_NA", initiateRiskCommunicationDate: "RTSL_ZEB_TEA_SPECIFY_DATE4", + earliestRespondDate: "RTSL_ZEB_TEA_EARLIEST_RESPOND_DATE", establishCoordination: "RTSL_ZEB_TEA_ESTABLISH_COORDINATION_MECHANISM", responseNarrative: "RTSL_ZEB_TEA_RESPONSE_NARRATIVE", incidentManager: "RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER", @@ -87,6 +90,20 @@ export function isStringInDiseaseOutbreakCodes(code: string): code is DiseaseOut export function getValueFromDiseaseOutbreak( diseaseOutbreak: DiseaseOutbreakEventBaseAttrs ): Record { + //Set Earliest Respond Date as the earliest of all early response action dates. + const responseActionDates: number[] = _c([ + diseaseOutbreak.earlyResponseActions.appropriateCaseManagement.date?.getTime(), + diseaseOutbreak.earlyResponseActions.conductEpidemiologicalAnalysis.getTime(), + diseaseOutbreak.earlyResponseActions.initiateInvestigation.getTime(), + diseaseOutbreak.earlyResponseActions.establishCoordination.getTime(), + diseaseOutbreak.earlyResponseActions.initiateRiskCommunication.date?.getTime(), + diseaseOutbreak.earlyResponseActions.initiatePublicHealthCounterMeasures.date?.getTime(), + diseaseOutbreak.earlyResponseActions.laboratoryConfirmation.date?.getTime(), + ]) + .compact() + .value(); + + const earliestRespondDate: Date = new Date(Math.min(...responseActionDates)); return { RTSL_ZEB_TEA_EVENT_NAME: diseaseOutbreak.name, RTSL_ZEB_TEA_DATA_SOURCE: diseaseOutbreak.dataSource, @@ -144,6 +161,7 @@ export function getValueFromDiseaseOutbreak( RTSL_ZEB_TEA_ESTABLISH_COORDINATION_MECHANISM: getDateAsIsoString( diseaseOutbreak.earlyResponseActions.establishCoordination ), + RTSL_ZEB_TEA_EARLIEST_RESPOND_DATE: getDateAsIsoString(earliestRespondDate), RTSL_ZEB_TEA_RESPONSE_NARRATIVE: diseaseOutbreak.earlyResponseActions.responseNarrative, RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER: diseaseOutbreak.incidentManagerName, RTSL_ZEB_TEA_NOTES: diseaseOutbreak.notes ?? "", diff --git a/src/data/repositories/consts/AnalyticsConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts similarity index 86% rename from src/data/repositories/consts/AnalyticsConstants.ts rename to src/data/repositories/consts/PerformanceOverviewConstants.ts index 7120f455..85ee59dd 100644 --- a/src/data/repositories/consts/AnalyticsConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -1,5 +1,13 @@ +import { + DiseaseNames, + HazardNames, + IncidentStatus, +} from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { Id } from "../../../domain/entities/Ref"; + export enum IndicatorsId { suspectedDisease = "jLvbkuvPdZ6", + hazardType = "Dzrw3Tf0ukB", event = "fyrLOW9Iwwv", era1 = "Ylmo2fEijff", era2 = "w4FOvRAyjEE", @@ -12,10 +20,9 @@ export enum IndicatorsId { notify1d = "HDa3nE7Elxj", respond7d = "yxVOW4lj4xP", province = "ouname", - manager = "createdbydisplayname", creationDate = "lastupdated", id = "tei", - eventDetectionDate = "enrollmentdate", + nationalIncidentStatus = "incidentStatus", } export const NB_OF_CASES = [ @@ -79,6 +86,23 @@ export const NB_OF_CASES = [ id: "yD6Rl5hHMg5", disease: "Zika fever", }, + { + id: "aYztCKYUy3o", + disease: "Animal type", + }, + { + id: "iJhV5JhqUh3", + disease: "Human type", + }, + { + id: "NQCfq7qVNqD", + disease: "Human and Animal type", + }, + + { + id: "KTPFFaddRMq", + disease: "Environmental type", + }, ]; export const NB_OF_DEATHS = [ @@ -144,7 +168,29 @@ export const NB_OF_DEATHS = [ }, ]; -export const NB_OF_ACTIVE_VERIFIED = [ +type EventTrackerCountIndicatorBase = { + id: Id; + type: "disease" | "hazard"; + name: DiseaseNames | HazardNames; + incidentStatus: IncidentStatus; + count?: number; +}; + +export type EventTrackerCountDiseaseIndicator = EventTrackerCountIndicatorBase & { + type: "disease"; + name: DiseaseNames; +}; + +export type EventTrackerCountHazardIndicator = EventTrackerCountIndicatorBase & { + type: "hazard"; + name: HazardNames; +}; + +export type EventTrackerCountIndicator = + | EventTrackerCountDiseaseIndicator + | EventTrackerCountHazardIndicator; + +export const evenTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ { id: "SGGbbu0AKUv", type: "disease", name: "Acute respiratory", incidentStatus: "Watch" }, { id: "QnhsQnEsp1p", type: "disease", name: "Acute respiratory", incidentStatus: "Alert" }, { id: "Rt5KNVqBEO7", type: "disease", name: "Acute respiratory", incidentStatus: "Respond" }, diff --git a/src/data/repositories/test/AlertSyncDataStoreTestRepository.ts b/src/data/repositories/test/AlertSyncDataStoreTestRepository.ts new file mode 100644 index 00000000..edc255b5 --- /dev/null +++ b/src/data/repositories/test/AlertSyncDataStoreTestRepository.ts @@ -0,0 +1,12 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { + AlertSyncOptions, + AlertSyncRepository, +} from "../../../domain/repositories/AlertSyncRepository"; +import { FutureData } from "../../api-futures"; + +export class AlertSyncDataStoreTestRepository implements AlertSyncRepository { + saveAlertSyncData(_options: AlertSyncOptions): FutureData { + return Future.success(undefined); + } +} diff --git a/src/data/repositories/test/AlertTestRepository.ts b/src/data/repositories/test/AlertTestRepository.ts index fbd2cfeb..4325a2de 100644 --- a/src/data/repositories/test/AlertTestRepository.ts +++ b/src/data/repositories/test/AlertTestRepository.ts @@ -1,9 +1,10 @@ +import { Alert } from "../../../domain/entities/alert/Alert"; import { Future } from "../../../domain/entities/generic/Future"; import { AlertOptions, AlertRepository } from "../../../domain/repositories/AlertRepository"; import { FutureData } from "../../api-futures"; export class AlertTestRepository implements AlertRepository { - updateAlerts(_alertOptions: AlertOptions): FutureData { - return Future.success(undefined); + updateAlerts(_alertOptions: AlertOptions): FutureData { + return Future.success([]); } } diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index aa12ba4a..821e03ca 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -2,7 +2,7 @@ import { DataSource, DiseaseOutbreakEvent, DiseaseOutbreakEventBaseAttrs, - IncidentStatus, + NationalIncidentStatus, } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../domain/entities/generic/Future"; import { Id, ConfigLabel } from "../../../domain/entities/Ref"; @@ -13,6 +13,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR get(id: Id): FutureData { return Future.success({ id: id, + status: "ACTIVE", name: "Disease Outbreak 1", dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, created: new Date(), @@ -24,7 +25,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "1", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, @@ -46,6 +47,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR return Future.success([ { id: "1", + status: "ACTIVE", name: "Disease Outbreak 1", dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, created: new Date(), @@ -57,7 +59,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "1", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, @@ -76,6 +78,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, { id: "2", + status: "ACTIVE", name: "Disease Outbreak 2", dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, created: new Date(), @@ -87,7 +90,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "2", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, diff --git a/src/data/repositories/test/OptionsTestRepository.ts b/src/data/repositories/test/OptionsTestRepository.ts index c6e65632..ae407d7a 100644 --- a/src/data/repositories/test/OptionsTestRepository.ts +++ b/src/data/repositories/test/OptionsTestRepository.ts @@ -89,6 +89,10 @@ export class OptionsTestRepository implements OptionsRepository { return Future.success([{ id: "1", name: "Test Hazard Type" }]); } + getHazardTypesByCode(): FutureData { + return Future.success([{ id: "1", name: "Test Hazard Type" }]); + } + getMainSyndromes(): FutureData { return Future.success([{ id: "1", name: "Test Main Syndrome" }]); } diff --git a/src/data/repositories/test/ProgramIndicatorsTestRepository.ts b/src/data/repositories/test/PerformanceOverviewTestRepository.ts similarity index 92% rename from src/data/repositories/test/ProgramIndicatorsTestRepository.ts rename to src/data/repositories/test/PerformanceOverviewTestRepository.ts index f367eea1..4bbc4a4f 100644 --- a/src/data/repositories/test/ProgramIndicatorsTestRepository.ts +++ b/src/data/repositories/test/PerformanceOverviewTestRepository.ts @@ -1,12 +1,12 @@ import { Future } from "../../../domain/entities/generic/Future"; -import { AnalyticsRepository } from "../../../domain/repositories/AnalyticsRepository"; +import { PerformanceOverviewRepository } from "../../../domain/repositories/PerformanceOverviewRepository"; import { FutureData } from "../../api-futures"; -export class ProgramIndicatorsTestRepository implements AnalyticsRepository { - getDiseasesTotal(): FutureData { +export class PerformanceOverviewTestRepository implements PerformanceOverviewRepository { + getTotalCardCounts(): FutureData { return Future.success(0); } - getProgramIndicators(): FutureData { + getPerformanceOverviewMetrics(): FutureData { return Future.success([ { id: "JPenxAnjdhY", diff --git a/src/data/repositories/test/TeamMemberTestRepository.ts b/src/data/repositories/test/TeamMemberTestRepository.ts index 18ea3f37..cfd50ce1 100644 --- a/src/data/repositories/test/TeamMemberTestRepository.ts +++ b/src/data/repositories/test/TeamMemberTestRepository.ts @@ -5,6 +5,20 @@ import { TeamMemberRepository } from "../../../domain/repositories/TeamMemberRep import { FutureData } from "../../api-futures"; export class TeamMemberTestRepository implements TeamMemberRepository { + getIncidentManagers(): FutureData { + const teamMember: TeamMember = new TeamMember({ + id: "incidentManager", + username: "incidentManager", + name: `Team Member Name test`, + email: `email@email.com`, + phone: `121-1234`, + role: { id: "1", name: "role" }, + status: "Available", + photo: new URL("https://www.example.com"), + }); + + return Future.success([teamMember]); + } getAll(): FutureData { const teamMember: TeamMember = new TeamMember({ id: "test", diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts new file mode 100644 index 00000000..0ee08d1b --- /dev/null +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -0,0 +1,76 @@ +import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { DataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { AlertOptions } from "../../../domain/repositories/AlertRepository"; +import { Maybe } from "../../../utils/ts-utils"; +import { Option } from "../../../domain/entities/Ref"; +import { alertOutbreakCodes } from "../consts/AlertConstants"; +import { getValueFromMap } from "./DiseaseOutbreakMapper"; +import { + dataSourceMap, + diseaseOutbreakCodes, + incidentStatusMap, +} from "../consts/DiseaseOutbreakConstants"; + +export function mapTrackedEntityAttributesToAlertOptions( + nationalTrackedEntity: D2TrackerTrackedEntity, + alertTrackedEntity: D2TrackerTrackedEntity +): AlertOptions { + if (!nationalTrackedEntity.trackedEntity) throw new Error("Tracked entity not found"); + + const fromDiseaseOutbreakMap = ( + key: keyof typeof diseaseOutbreakCodes, + trackedEntity: D2TrackerTrackedEntity + ) => getValueFromMap(key, trackedEntity); + + const fromAlertOutbreakMap = ( + key: keyof typeof alertOutbreakCodes, + trackedEntity: D2TrackerTrackedEntity + ) => getAlertValueFromMap(key, trackedEntity); + + const dataSource = dataSourceMap[fromDiseaseOutbreakMap("dataSource", nationalTrackedEntity)]; + const incidentStatus = + incidentStatusMap[fromDiseaseOutbreakMap("incidentStatus", nationalTrackedEntity)]; + + if (!dataSource || !incidentStatus) throw new Error("Data source or incident status not valid"); + + const diseaseOutbreak: AlertOptions = { + eventId: nationalTrackedEntity.trackedEntity, + dataSource: dataSource, + hazardTypeCode: fromAlertOutbreakMap("hazardType", alertTrackedEntity), + suspectedDiseaseCode: fromAlertOutbreakMap("suspectedDisease", alertTrackedEntity), + incidentStatus: incidentStatus, + }; + + return diseaseOutbreak; +} + +export function getAlertValueFromMap( + key: keyof typeof alertOutbreakCodes, + trackedEntity: D2TrackerTrackedEntity +): string { + return ( + trackedEntity.attributes?.find(attribute => attribute.code === alertOutbreakCodes[key]) + ?.value ?? "" + ); +} + +export function getOutbreakKey(options: { + dataSource: DataSource; + outbreakValue: Maybe; + hazardTypes: Option[]; + suspectedDiseases: Option[]; +}): string { + const { dataSource, outbreakValue, hazardTypes, suspectedDiseases } = options; + + const diseaseName = suspectedDiseases.find(disease => disease.id === outbreakValue)?.name; + const hazardName = hazardTypes.find(hazardType => hazardType.id === outbreakValue)?.name; + + if (!diseaseName && !hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + switch (dataSource) { + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: + return hazardName ?? ""; + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: + return diseaseName ?? ""; + } +} diff --git a/src/data/repositories/utils/DateTimeHelper.ts b/src/data/repositories/utils/DateTimeHelper.ts index b7501bc6..a6aff3b4 100644 --- a/src/data/repositories/utils/DateTimeHelper.ts +++ b/src/data/repositories/utils/DateTimeHelper.ts @@ -15,7 +15,11 @@ export function getDateAsIsoString(date: Maybe): string { export function getDateAsMonthYearString(date: Date): string { try { - return date.toLocaleString("default", { month: "long", year: "numeric" }); + return date.toLocaleString("default", { + day: "numeric", + month: "long", + year: "numeric", + }); } catch (e) { console.debug(e); return ""; diff --git a/src/data/repositories/utils/DiseaseOutbreakMapper.ts b/src/data/repositories/utils/DiseaseOutbreakMapper.ts index 53a7eb43..1e2950fe 100644 --- a/src/data/repositories/utils/DiseaseOutbreakMapper.ts +++ b/src/data/repositories/utils/DiseaseOutbreakMapper.ts @@ -44,6 +44,7 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( const diseaseOutbreak: DiseaseOutbreakEventBaseAttrs = { id: trackedEntity.trackedEntity, + status: trackedEntity.enrollments?.[0]?.status ?? "ACTIVE", //Zebra Outbreak has only one enrollment name: fromMap("name"), dataSource: dataSource, created: trackedEntity.createdAt ? new Date(trackedEntity.createdAt) : new Date(), @@ -168,7 +169,7 @@ export function mapDiseaseOutbreakEventToTrackedEntityAttributes( } } -function getValueFromMap( +export function getValueFromMap( key: keyof typeof diseaseOutbreakCodes, trackedEntity: D2TrackerTrackedEntity ): string { diff --git a/src/data/repositories/utils/MetadataHelper.ts b/src/data/repositories/utils/MetadataHelper.ts index b69f00af..7ea8ddb0 100644 --- a/src/data/repositories/utils/MetadataHelper.ts +++ b/src/data/repositories/utils/MetadataHelper.ts @@ -1,6 +1,10 @@ -import { Id } from "../../../domain/entities/Ref"; +import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { Id, Ref } from "../../../domain/entities/Ref"; import { D2Api } from "../../../types/d2-api"; -import { apiToFuture } from "../../api-futures"; +import { apiToFuture, FutureData } from "../../api-futures"; +import { assertOrError } from "./AssertOrError"; +import { Attribute } from "@eyeseetea/d2-api/api/trackedEntityInstances"; +import { Maybe } from "../../../utils/ts-utils"; export function getProgramTEAsMetadata(api: D2Api, programId: Id) { return apiToFuture( @@ -22,6 +26,34 @@ export function getProgramTEAsMetadata(api: D2Api, programId: Id) { ); } +export function getUserGroupByCode(api: D2Api, code: string): FutureData { + return apiToFuture( + api.metadata.get({ + userGroups: { + fields: { + id: true, + }, + filter: { + code: { eq: code }, + }, + }, + }) + ) + .flatMap(response => assertOrError(response.userGroups[0], `User group ${code}`)) + .map(userGroup => userGroup); +} + +export function getTEAttributeById( + trackedEntity: D2TrackerTrackedEntity, + attributeId: Id +): Maybe { + if (!trackedEntity.attributes) return undefined; + + return trackedEntity.attributes + .map(attribute => ({ attribute: attribute.attribute, value: attribute.value })) + .find(attribute => attribute.attribute === attributeId); +} + export function getProgramStage(api: D2Api, stageId: Id) { return apiToFuture( api.models.programStages.get({ diff --git a/src/data/repositories/utils/NotificationMapper.ts b/src/data/repositories/utils/NotificationMapper.ts new file mode 100644 index 00000000..54a03bd2 --- /dev/null +++ b/src/data/repositories/utils/NotificationMapper.ts @@ -0,0 +1,22 @@ +import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; +import { getAlertValueFromMap } from "./AlertOutbreakMapper"; +import { NotificationOptions } from "../../../domain/repositories/NotificationRepository"; +import { getValueFromMap } from "./DiseaseOutbreakMapper"; + +export function getNotificationOptionsFromTrackedEntity( + alertTrackedEntity: D2TrackerTrackedEntity +): NotificationOptions { + const verificationStatus = getAlertValueFromMap("verificationStatus", alertTrackedEntity); + const incidentManager = getAlertValueFromMap("incidentManager", alertTrackedEntity); + const emergenceDate = getValueFromMap("emergedDate", alertTrackedEntity); + const detectionDate = getValueFromMap("detectedDate", alertTrackedEntity); + const notificationDate = getValueFromMap("notifiedDate", alertTrackedEntity); + + return { + detectionDate: detectionDate, + emergenceDate: emergenceDate, + incidentManager: incidentManager, + notificationDate: notificationDate, + verificationStatus: verificationStatus, + }; +} diff --git a/src/data/repositories/utils/getAllTrackedEntities.ts b/src/data/repositories/utils/getAllTrackedEntities.ts index 31e26798..845602b6 100644 --- a/src/data/repositories/utils/getAllTrackedEntities.ts +++ b/src/data/repositories/utils/getAllTrackedEntities.ts @@ -30,6 +30,9 @@ export async function getAllTrackedEntitiesAsync( orgUnit: true, trackedEntity: true, trackedEntityType: true, + enrollments: { + status: true, + }, }, }) .getData(); diff --git a/src/domain/entities/alert/Alert.ts b/src/domain/entities/alert/Alert.ts new file mode 100644 index 00000000..a992b01b --- /dev/null +++ b/src/domain/entities/alert/Alert.ts @@ -0,0 +1,10 @@ +export type Alert = { + id: string; + district: string; +}; + +export enum VerificationStatus { + RTSL_ZEB_AL_OS_VERIFICATION_VERIFIED = "RTSL_ZEB_AL_OS_VERIFICATION_VERIFIED", + RTSL_ZEB_AL_OS_VERIFICATION_PENDING_VERIFICATION = "RTSL_ZEB_AL_OS_VERIFICATION_PENDING_VERIFICATION", + RTSL_ZEB_AL_OS_VERIFICATION_NOT_AN_EVENT = "RTSL_ZEB_AL_OS_VERIFICATION_NOT_AN_EVENT", +} diff --git a/src/domain/entities/alert/AlertData.ts b/src/domain/entities/alert/AlertData.ts new file mode 100644 index 00000000..dade4a44 --- /dev/null +++ b/src/domain/entities/alert/AlertData.ts @@ -0,0 +1,30 @@ +import { Maybe } from "../../../utils/ts-utils"; +import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; +import { Id } from "../Ref"; +import { Alert } from "./Alert"; + +export type AlertData = { + alert: Alert; + dataSource: DataSource; + outbreakData: { + id: string; + value: string; + }; +}; + +export type AlertSynchronizationData = { + lastSyncTime: string; + type: string; + nationalDiseaseOutbreakEventId: Id; + alerts: { + alertId: string; + eventDate: Maybe; + orgUnit: Maybe; + suspectedCases: string; + probableCases: string; + confirmedCases: string; + deaths: string; + }[]; +} & { + [key in "disease" | "hazard"]?: string; +}; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index 011803bd..316d335f 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -19,7 +19,7 @@ export const hazardTypes = [ export type HazardType = (typeof hazardTypes)[number]; -export enum IncidentStatus { +export enum NationalIncidentStatus { RTSL_ZEB_OS_INCIDENT_STATUS_WATCH = "RTSL_ZEB_OS_INCIDENT_STATUS_WATCH", RTSL_ZEB_OS_INCIDENT_STATUS_ALERT = "RTSL_ZEB_OS_INCIDENT_STATUS_ALERT", RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND = "RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND", @@ -54,6 +54,7 @@ type EarlyResponseActions = { }; export type DiseaseOutbreakEventBaseAttrs = NamedRef & { + status: "ACTIVE" | "COMPLETED" | "CANCELLED"; created: Date; lastUpdated: Date; createdByName: Maybe; @@ -64,7 +65,7 @@ export type DiseaseOutbreakEventBaseAttrs = NamedRef & { notificationSourceCode: Code; areasAffectedProvinceIds: Id[]; areasAffectedDistrictIds: Id[]; - incidentStatus: IncidentStatus; + incidentStatus: NationalIncidentStatus; emerged: DateWithNarrative; detected: DateWithNarrative; notified: DateWithNarrative; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts new file mode 100644 index 00000000..8409e199 --- /dev/null +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts @@ -0,0 +1,27 @@ +import { Maybe } from "../../../utils/ts-utils"; +import { TeamMember } from "../incident-management-team/TeamMember"; +import { Option } from "../Ref"; +import { Rule } from "../Rule"; +import { ValidationErrorKey } from "../ValidationError"; +import { DiseaseOutbreakEventBaseAttrs } from "./DiseaseOutbreakEvent"; + +export type DiseaseOutbreakEventOptions = { + dataSources: Option[]; + hazardTypes: Option[]; + mainSyndromes: Option[]; + suspectedDiseases: Option[]; + notificationSources: Option[]; + incidentStatus: Option[]; + incidentManagers: TeamMember[]; +}; + +export type DiseaseOutbreakEventLabels = { + errors: Record; +}; + +export type DiseaseOutbreakEventWithOptions = { + diseaseOutbreakEvent: Maybe; + options: DiseaseOutbreakEventOptions; + labels: DiseaseOutbreakEventLabels; + rules: Rule[]; +}; diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts new file mode 100644 index 00000000..58e72586 --- /dev/null +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -0,0 +1,69 @@ +import { Id } from "../Ref"; + +export type DiseaseNames = + | "AFP" + | "Acute VHF" + | "Acute respiratory" + | "Anthrax" + | "Bacterial meningitis" + | "COVID19" + | "Cholera" + | "Diarrhoea with blood" + | "Measles" + | "Monkeypox" + | "Neonatal tetanus" + | "Plague" + | "SARIs" + | "Typhoid fever" + | "Zika fever"; + +export type HazardNames = + | "Biological: Animal" + | "Biological: Human" + | "Biological: Human and Animal" + | "Environmental"; + +export type PerformanceOverviewMetrics = { + id: Id; + event: string; + province: string; + duration: string; + manager: string; + cases: string; + deaths: string; + era1: string; + era2: string; + era3: string; + era4: string; + era5: string; + era6: string; + era7: string; + detect7d: string; + notify1d: string; + respond7d: string; + creationDate: string; + suspectedDisease: DiseaseNames; + hazardType: HazardNames; + nationalIncidentStatus: string; +}; + +export type IncidentStatus = "Watch" | "Alert" | "Respond"; + +type BaseCounts = { + name: DiseaseNames | HazardNames; + total: number; + incidentStatus: IncidentStatus; + type: "disease" | "hazard"; +}; + +type DiseaseCounts = BaseCounts & { + name: DiseaseNames; + type: "disease"; +}; + +type HazardCounts = BaseCounts & { + name: HazardNames; + type: "hazard"; +}; + +export type TotalCardCounts = DiseaseCounts | HazardCounts; diff --git a/src/domain/repositories/AlertRepository.ts b/src/domain/repositories/AlertRepository.ts index fa361eee..3725603f 100644 --- a/src/domain/repositories/AlertRepository.ts +++ b/src/domain/repositories/AlertRepository.ts @@ -1,19 +1,20 @@ import { FutureData } from "../../data/api-futures"; import { Maybe } from "../../utils/ts-utils"; +import { Alert } from "../entities/alert/Alert"; import { DataSource, - IncidentStatus, + NationalIncidentStatus, } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../entities/Ref"; export interface AlertRepository { - updateAlerts(alertOptions: AlertOptions): FutureData; + updateAlerts(alertOptions: AlertOptions): FutureData; } export type AlertOptions = { dataSource: DataSource; eventId: Id; hazardTypeCode: Maybe; - incidentStatus: IncidentStatus; + incidentStatus: NationalIncidentStatus; suspectedDiseaseCode: Maybe; }; diff --git a/src/domain/repositories/AlertSyncRepository.ts b/src/domain/repositories/AlertSyncRepository.ts new file mode 100644 index 00000000..8148e54c --- /dev/null +++ b/src/domain/repositories/AlertSyncRepository.ts @@ -0,0 +1,19 @@ +import { FutureData } from "../../data/api-futures"; +import { DataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Id, Option } from "../entities/Ref"; +import { Maybe } from "../../utils/ts-utils"; +import { Alert } from "../entities/alert/Alert"; + +export interface AlertSyncRepository { + saveAlertSyncData(options: AlertSyncOptions): FutureData; +} + +export type AlertSyncOptions = { + alert: Alert; + dataSource: DataSource; + nationalDiseaseOutbreakEventId: Id; + hazardTypeCode: Maybe; + suspectedDiseaseCode: Maybe; + hazardTypes: Option[]; + suspectedDiseases: Option[]; +}; diff --git a/src/domain/repositories/AnalyticsRepository.ts b/src/domain/repositories/AnalyticsRepository.ts deleted file mode 100644 index a65f5f83..00000000 --- a/src/domain/repositories/AnalyticsRepository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FutureData } from "../../data/api-futures"; -import { ProgramIndicatorBaseAttrs } from "../../data/repositories/AnalyticsD2Repository"; - -export interface AnalyticsRepository { - getProgramIndicators(): FutureData; - getDiseasesTotal( - allProvincesIds: string[], - singleSelectFilters?: Record, - multiSelectFilters?: Record - ): FutureData; -} diff --git a/src/domain/repositories/NotificationRepository.ts b/src/domain/repositories/NotificationRepository.ts new file mode 100644 index 00000000..8f36650f --- /dev/null +++ b/src/domain/repositories/NotificationRepository.ts @@ -0,0 +1,20 @@ +import { FutureData } from "../../data/api-futures"; +import { Ref } from "../entities/Ref"; + +export interface NotificationRepository { + save(notification: Notification): FutureData; +} + +export type Notification = { + subject: string; + text: string; + userGroups: Ref[]; +}; + +export type NotificationOptions = { + detectionDate: string; + emergenceDate: string; + incidentManager: string; + notificationDate: string; + verificationStatus: string; +}; diff --git a/src/domain/repositories/OptionsRepository.ts b/src/domain/repositories/OptionsRepository.ts index 13227042..7a56a155 100644 --- a/src/domain/repositories/OptionsRepository.ts +++ b/src/domain/repositories/OptionsRepository.ts @@ -25,6 +25,7 @@ export interface OptionsRepository { getNotificationSource(optionCode: Code): FutureData
) : ( @@ -104,14 +110,21 @@ export const FormSummary: React.FC = React.memo(props => { const SummaryContainer = styled.div` display: flex; + flex-wrap: wrap; width: max-content; - align-items: center; + align-items: flex-start; margin-top: 0rem; + @media (max-width: 1200px) { + flex-direction: column; + } `; const SummaryColumn = styled.div` - flex: 1; padding-right: 2rem; color: ${props => props.theme.palette.text.hint}; min-width: fit-content; `; + +const StyledType = styled(Typography)` + color: ${props => props.theme.palette.text.hint}; +`; diff --git a/src/webapp/components/form/form-summary/useFormSummary.ts b/src/webapp/components/form/form-summary/useFormSummary.ts new file mode 100644 index 00000000..24732b5b --- /dev/null +++ b/src/webapp/components/form/form-summary/useFormSummary.ts @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../../../contexts/app-context"; +import { Id } from "../../../../domain/entities/Ref"; +import { + DataSource, + DiseaseOutbreakEvent, +} from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { User } from "../../user-selector/UserSelector"; + +import { Maybe } from "../../../../utils/ts-utils"; +import { + getDateAsLocaleDateTimeString, + getDateAsMonthYearString, +} from "../../../../data/repositories/utils/DateTimeHelper"; +import { mapTeamMemberToUser } from "../../../pages/form-page/mapEntityToFormState"; + +const EventTypeLabel = "Event type"; +const DiseaseLabel = "Disease"; +type LabelWithValue = { + label: string; + value: string; +}; + +type FormSummary = { + subTitle: string; + summary: LabelWithValue[]; + incidentManager: Maybe; + notes: string; +}; +export function useFormSummary(id: Id) { + const { compositionRoot } = useAppContext(); + const [formSummary, setFormSummary] = useState(); + const [summaryError, setSummaryError] = useState(); + + useEffect(() => { + compositionRoot.diseaseOutbreakEvent.get.execute(id).run( + diseaseOutbreakEvent => { + setFormSummary(mapDiseaseOutbreakEventToFormSummary(diseaseOutbreakEvent)); + }, + err => { + console.debug(err); + setSummaryError(`Event tracker with id: ${id} does not exist`); + } + ); + }, [compositionRoot.diseaseOutbreakEvent.get, id]); + + const mapDiseaseOutbreakEventToFormSummary = ( + diseaseOutbreakEvent: DiseaseOutbreakEvent + ): FormSummary => { + const dataSourceLabelValue: LabelWithValue = + diseaseOutbreakEvent.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS + ? { + label: EventTypeLabel, + value: diseaseOutbreakEvent.hazardType ?? "", + } + : { + label: DiseaseLabel, + value: diseaseOutbreakEvent.suspectedDisease?.name ?? "", + }; + return { + subTitle: diseaseOutbreakEvent.name, + summary: [ + { + label: "Last updated", + value: getDateAsLocaleDateTimeString(diseaseOutbreakEvent.lastUpdated), + }, + dataSourceLabelValue, + { + label: "Event ID", + value: diseaseOutbreakEvent.id, + }, + { + label: "Emergence date", + value: getDateAsMonthYearString(diseaseOutbreakEvent.emerged.date), + }, + { + label: "Detection date", + value: getDateAsMonthYearString(diseaseOutbreakEvent.detected.date), + }, + { + label: "Notification date", + value: getDateAsMonthYearString(diseaseOutbreakEvent.notified.date), + }, + ], + incidentManager: diseaseOutbreakEvent.incidentManager + ? mapTeamMemberToUser(diseaseOutbreakEvent.incidentManager) + : undefined, + notes: diseaseOutbreakEvent.notes ?? "", + }; + }; + + return { formSummary, summaryError }; +} diff --git a/src/webapp/components/table/statistic-table/StatisticTable.tsx b/src/webapp/components/table/statistic-table/StatisticTable.tsx index 1c3e53a7..15b33579 100644 --- a/src/webapp/components/table/statistic-table/StatisticTable.tsx +++ b/src/webapp/components/table/statistic-table/StatisticTable.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { Dispatch, SetStateAction, useCallback } from "react"; import styled from "styled-components"; import i18n from "../../../../utils/i18n"; import { @@ -17,10 +17,11 @@ import { useTableCell } from "./useTableCell"; import { useStatisticCalculations } from "./useStatisticCalculations"; import { ColoredCell } from "./ColoredCell"; import { CalculationRow } from "./CalculationRow"; -import { Id } from "../../../../domain/entities/Ref"; import { Order } from "../../../pages/dashboard/usePerformanceOverview"; import { Option } from "../../utils/option"; +import { Id } from "../../../../domain/entities/Ref"; import { Maybe } from "../../../../utils/ts-utils"; +import { PerformanceOverviewMetrics } from "../../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; export type TableColumn = { value: string; @@ -50,8 +51,8 @@ export type StatisticTableProps = { [key: TableColumn["value"]]: string; }[]; filters: FiltersConfig[]; - order?: Order; - setOrder?: (order: Order) => void; + order: Maybe; + setOrder: Dispatch>>; goToEvent: (id: Maybe) => void; }; @@ -77,17 +78,19 @@ export const StatisticTable: React.FC = React.memo( ); const onOrderBy = useCallback( - (value: string) => - setOrder && - setOrder({ - name: value, - direction: - order?.name === value - ? order?.direction === "asc" - ? "desc" - : "asc" - : "asc", - }), + (value: string) => { + setOrder(prevOrder => { + return { + name: value as keyof PerformanceOverviewMetrics, + direction: + prevOrder?.name === value + ? order?.direction === "asc" + ? "desc" + : "asc" + : "asc", + }; + }); + }, [order, setOrder] ); diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index a8ea573f..6b52db55 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -5,11 +5,11 @@ import { Layout } from "../../components/layout/Layout"; import { Section } from "../../components/section/Section"; import { StatisticTable } from "../../components/table/statistic-table/StatisticTable"; import { usePerformanceOverview } from "./usePerformanceOverview"; -import { useDiseasesTotal } from "./useDiseasesTotal"; -import { StatsCard, StatsCardProps } from "../../components/stats-card/StatsCard"; +import { useCardCounts } from "./useCardCounts"; +import { StatsCard } from "../../components/stats-card/StatsCard"; import styled from "styled-components"; import { MultipleSelector } from "../../components/selector/MultipleSelector"; -import { Id } from "@eyeseetea/d2-api"; +import { Id } from "../../../domain/entities/Ref"; import { Maybe } from "../../../utils/ts-utils"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useAlertsActiveVerifiedFilters } from "./useAlertsActiveVerifiedFilters"; @@ -36,7 +36,7 @@ export const DashboardPage: React.FC = React.memo(() => { editRiskAssessmentColumns, } = usePerformanceOverview(); - const { diseasesTotal } = useDiseasesTotal(singleSelectFilters, multiSelectFilters); + const { cardCounts } = useCardCounts(singleSelectFilters, multiSelectFilters); const { goTo } = useRoutes(); @@ -45,33 +45,6 @@ export const DashboardPage: React.FC = React.memo(() => { goTo(RouteName.EVENT_TRACKER, { id }); }; - const performances: StatsCardProps[] = [ - { - title: "Detection", - stat: "57", - pretitle: "4 events", - color: "green", - }, - { - title: "Notification", - stat: "43", - pretitle: "3 events", - color: "red", - }, - { - title: "Response", - stat: "57", - pretitle: "4 events", - color: "green", - }, - { - title: "All targets", - stat: "14", - pretitle: "1 events", - color: "grey", - }, - ]; - return (
@@ -85,12 +58,7 @@ export const DashboardPage: React.FC = React.memo(() => { label={i18n.t(label)} placeholder={i18n.t(placeholder)} options={options || []} - onChange={(values: string[]) => - setMultiSelectFilters({ - ...multiSelectFilters, - [id]: values, - }) - } + onChange={(values: string[]) => setMultiSelectFilters(id, values)} /> ) : ( { )} - setMultiSelectFilters({ ...multiSelectFilters, duration: dates }) - } + onChange={(dates: string[]) => setMultiSelectFilters("duration", dates)} placeholder={i18n.t("Select duration")} label={i18n.t("Duration")} /> - {diseasesTotal && - diseasesTotal.map((disease, index) => ( - - ))} + {cardCounts.map((cardCount, index) => ( + + ))}
@@ -133,22 +98,7 @@ export const DashboardPage: React.FC = React.memo(() => { multiSelectFilters={multiSelectFilters} />
-
- - {performances && - performances.map((per, index) => ( - - ))} - -
+
TBD
{ + setMultiSelectFilters(prevMultiSelectFilters => ({ + ...prevMultiSelectFilters, + [id]: values, + })); + }, []); + // Initialize filter options based on diseasesTotal useEffect(() => { const buildFiltersConfig = (): FiltersConfig[] => { const createOptions = (key: "disease" | "hazard") => - _c(NB_OF_ACTIVE_VERIFIED) + _c(evenTrackerCountsIndicatorMap) .filter(value => value.type === key) .uniqBy(value => value.name) .map(value => ({ @@ -65,6 +72,9 @@ export function useAlertsActiveVerifiedFilters() { .value(); + const diseaseOptions = createOptions("disease"); + const hazardOptions = createOptions("hazard"); + return [ { id: "incidentStatus", @@ -82,14 +92,14 @@ export function useAlertsActiveVerifiedFilters() { label: "Disease", placeholder: "Select Disease", type: "singleselector", - options: createOptions("disease"), + options: diseaseOptions, }, { id: "hazard", label: "Hazard Type", placeholder: "Select Hazard Type", type: "singleselector", - options: createOptions("hazard"), + options: hazardOptions, }, { id: "province", @@ -108,7 +118,7 @@ export function useAlertsActiveVerifiedFilters() { singleSelectFilters, setSingleSelectFilters: handleSetSingleSelectFilters, multiSelectFilters, - setMultiSelectFilters, + setMultiSelectFilters: handleSetMultiSelectFilters, }; } diff --git a/src/webapp/pages/dashboard/useDiseasesTotal.ts b/src/webapp/pages/dashboard/useCardCounts.ts similarity index 66% rename from src/webapp/pages/dashboard/useDiseasesTotal.ts rename to src/webapp/pages/dashboard/useCardCounts.ts index ad3d91ba..10d065ca 100644 --- a/src/webapp/pages/dashboard/useDiseasesTotal.ts +++ b/src/webapp/pages/dashboard/useCardCounts.ts @@ -1,30 +1,25 @@ import { useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import _ from "../../../domain/entities/generic/Collection"; - -type State = { - diseasesTotal: any[]; - isLoading: boolean; -}; +import { TotalCardCounts } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; export type Order = { name: string; direction: "asc" | "desc" }; -export function useDiseasesTotal( +export function useCardCounts( singleSelectFilters: Record, multiSelectFilters: Record -): State { +) { const { compositionRoot } = useAppContext(); - - const [diseasesTotal, setDiseasesTotal] = useState([]); + const [cardCounts, setCardCounts] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect(() => { setIsLoading(true); - compositionRoot.analytics.getDiseasesTotal + compositionRoot.performanceOverview.getTotalCardCounts .execute(singleSelectFilters, multiSelectFilters) .run( diseasesTotal => { - setDiseasesTotal(diseasesTotal); + setCardCounts(diseasesTotal); setIsLoading(false); }, error => { @@ -32,10 +27,10 @@ export function useDiseasesTotal( setIsLoading(false); } ); - }, [compositionRoot.analytics.getDiseasesTotal, multiSelectFilters, singleSelectFilters]); + }, [compositionRoot, singleSelectFilters, multiSelectFilters]); return { - diseasesTotal, + cardCounts, isLoading, }; } diff --git a/src/webapp/pages/dashboard/usePerformanceOverview.ts b/src/webapp/pages/dashboard/usePerformanceOverview.ts index dd917349..44dddddf 100644 --- a/src/webapp/pages/dashboard/usePerformanceOverview.ts +++ b/src/webapp/pages/dashboard/usePerformanceOverview.ts @@ -1,62 +1,90 @@ -import { useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import _ from "../../../domain/entities/generic/Collection"; - import { FiltersConfig, TableColumn } from "../../components/table/statistic-table/StatisticTable"; -import { ProgramIndicatorBaseAttrs } from "../../../data/repositories/AnalyticsD2Repository"; +import { Maybe } from "../../../utils/ts-utils"; +import { PerformanceOverviewMetrics } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { NationalIncidentStatus } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type State = { columns: TableColumn[]; - dataPerformanceOverview: any[]; + dataPerformanceOverview: PerformanceOverviewMetrics[]; columnRules: { [key: string]: number }; editRiskAssessmentColumns: string[]; filters: FiltersConfig[]; - order?: Order; - setOrder: (order: Order) => void; + order: Maybe; + setOrder: Dispatch>>; isLoading: boolean; }; -export type Order = { name: string; direction: "asc" | "desc" }; +export type Order = { name: keyof PerformanceOverviewMetrics; direction: "asc" | "desc" }; + export function usePerformanceOverview(): State { const { compositionRoot } = useAppContext(); const [dataPerformanceOverview, setDataPerformanceOverview] = useState< - ProgramIndicatorBaseAttrs[] + PerformanceOverviewMetrics[] >([]); const [isLoading, setIsLoading] = useState(false); const [order, setOrder] = useState(); useEffect(() => { - if (dataPerformanceOverview) { - setDataPerformanceOverview(newDataPerformanceOverview => - _(newDataPerformanceOverview) - .orderBy([ - [ - (dataPerformanceOverviewData: ProgramIndicatorBaseAttrs) => { - const value = - dataPerformanceOverviewData[ - (order?.name as keyof ProgramIndicatorBaseAttrs) || - "creationDate" - ]; - return Number.isNaN(Number(value)) ? value : Number(value); - }, - order?.direction || "asc", - ], - ]) - .value() + if (dataPerformanceOverview.length && order) { + setDataPerformanceOverview( + (prevDataPerformanceOverview: PerformanceOverviewMetrics[]) => { + const newDataPerformanceOverview = _(prevDataPerformanceOverview) + .orderBy([ + [ + item => + Number.isNaN(Number(item[order.name])) + ? item[order.name] + : Number(item[order.name]), + order.direction, + ], + ]) + .toArray(); + + return newDataPerformanceOverview; + } ); } - }, [order]); + }, [order, dataPerformanceOverview]); + const getNationalIncidentStatusString = useCallback((status: string): string => { + switch (status as NationalIncidentStatus) { + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_ALERT: + return "Alert"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED: + return "Closed"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED: + return "Discarded"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND: + return "Respond"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH: + return "Watch"; + } + }, []); + + const mapEntityToTableData = useCallback( + (programIndicator: PerformanceOverviewMetrics): PerformanceOverviewMetrics => { + return { + ...programIndicator, + nationalIncidentStatus: getNationalIncidentStatusString( + programIndicator.nationalIncidentStatus + ), + event: programIndicator.event, + }; + }, + [getNationalIncidentStatusString] + ); useEffect(() => { setIsLoading(true); - compositionRoot.analytics.getProgramIndicators.execute().run( + compositionRoot.performanceOverview.getPerformanceOverviewMetrics.execute().run( programIndicators => { - setDataPerformanceOverview( - programIndicators.map((data: ProgramIndicatorBaseAttrs) => - mapEntityToTableData(data) - ) + const mappedData = programIndicators.map((data: PerformanceOverviewMetrics) => + mapEntityToTableData(data) ); + setDataPerformanceOverview(mappedData); setIsLoading(false); }, error => { @@ -64,7 +92,7 @@ export function usePerformanceOverview(): State { setIsLoading(false); } ); - }, [compositionRoot.analytics.getProgramIndicators]); + }, [compositionRoot.performanceOverview.getPerformanceOverviewMetrics, mapEntityToTableData]); const columns: TableColumn[] = [ { label: "Event", value: "event" }, @@ -83,6 +111,7 @@ export function usePerformanceOverview(): State { { label: "ERA6", value: "era6" }, { label: "ERA7", value: "era7" }, { label: "Respond 7d", dark: true, value: "respond7d" }, + { label: "Incident Status", value: "nationalIncidentStatus" }, ]; const editRiskAssessmentColumns = ["era1", "era2", "era3", "era4", "era5", "era6", "era7"]; const columnRules: { [key: string]: number } = { @@ -90,14 +119,6 @@ export function usePerformanceOverview(): State { notify1d: 1, respond7d: 7, }; - const mapEntityToTableData = ( - programIndicator: ProgramIndicatorBaseAttrs - ): ProgramIndicatorBaseAttrs => { - return { - ...programIndicator, - event: programIndicator.event + " (" + programIndicator.suspectedDisease + ")", - }; - }; const filters: FiltersConfig[] = [ { value: "event", label: "Event", type: "multiselector" }, diff --git a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts index e78a1d0d..8a3dd3a2 100644 --- a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts +++ b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts @@ -27,6 +27,7 @@ export type FormSummaryData = { subTitle: string; summary: LabelWithValue[]; incidentManager: Maybe; + notes: string; }; export function useDiseaseOutbreakEvent(id: Id) { const { compositionRoot } = useAppContext(); @@ -92,6 +93,7 @@ export function useDiseaseOutbreakEvent(id: Id) { incidentManager: diseaseOutbreakEvent.incidentManager ? mapTeamMemberToUser(diseaseOutbreakEvent.incidentManager) : undefined, + notes: diseaseOutbreakEvent.notes || "", }; }; diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts index 1f348cd7..34859d6b 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts @@ -151,6 +151,7 @@ export function mapFormStateToDiseaseOutbreakEventData( const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { id: diseaseOutbreakEvent?.id || "", + status: diseaseOutbreakEvent?.status || "ACTIVE", created: diseaseOutbreakEvent?.created || new Date(), lastUpdated: diseaseOutbreakEvent?.lastUpdated || new Date(), createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 958f321d..e568b0c7 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -2,7 +2,7 @@ import { DataSource, DiseaseOutbreakEventBaseAttrs, HazardType, - IncidentStatus, + NationalIncidentStatus, } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { FormState } from "../../components/form/FormState"; import { diseaseOutbreakEventFieldIds } from "./disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState"; @@ -127,7 +127,7 @@ function mapFormStateToDiseaseOutbreakEvent( incidentStatus: getStringFieldValue( diseaseOutbreakEventFieldIds.incidentStatus, allFields - ) as IncidentStatus, + ) as NationalIncidentStatus, emerged: { date: getDateFieldValue(diseaseOutbreakEventFieldIds.emergedDate, allFields) as Date, narrative: getStringFieldValue( @@ -216,6 +216,7 @@ function mapFormStateToDiseaseOutbreakEvent( const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { id: diseaseOutbreakEvent?.id || "", + status: diseaseOutbreakEvent?.status || "ACTIVE", created: diseaseOutbreakEvent?.created || new Date(), lastUpdated: diseaseOutbreakEvent?.lastUpdated || new Date(), createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, diff --git a/yarn.lock b/yarn.lock index 6a7d22f0..89e95e32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3419,10 +3419,10 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== -"@eyeseetea/d2-api@1.16.0-beta.9": - version "1.16.0-beta.9" - resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.16.0-beta.9.tgz#bd318b62d8c94ea4e490c8e9b90461869f748c25" - integrity sha512-ASOcekMZoOOAZ9+Aq4I34qkiHW4sZFWoB5V8V51WAgYTv0ONoS8uLdFpmc1kFXJ5jsWC7IgSnE79rkLLZ9h9mg== +"@eyeseetea/d2-api@1.16.0-beta.12": + version "1.16.0-beta.12" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.16.0-beta.12.tgz#02fd26e7a28f2debf7d890364a077020e4eab7b7" + integrity sha512-5VlaiWPrpuIHlCKGB75H4ndd7W0s203CR7qBSmfcqAMhmKnybPI7tTB97DJxF+VnIxwRvfsZ0g8ATKVlUTz0Vg== dependencies: "@babel/runtime" "^7.5.4" "@dhis2/d2-i18n" "^1.0.5" @@ -6172,6 +6172,11 @@ dot-prop@^5.2.0: dependencies: is-obj "^2.0.0" +dotenv@^16.4.5: + version "16.4.5" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.4.5.tgz#cdd3b3b604cb327e286b4762e13502f717cb099f" + integrity sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg== + dotenv@^8.0.0: version "8.6.0" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" From 0543d7170f38941ab790e0b305ca6081d725e9e3 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 2 Oct 2024 08:44:32 +0200 Subject: [PATCH 27/29] Change use cases names --- src/CompositionRoot.ts | 6 +++--- .../{GetAllOrgUnits.ts => GetAllOrgUnitsUseCase.ts} | 2 +- ...DiseasesTotalUseCase.ts => GetTotalCardCountsUseCase.ts} | 0 3 files changed, 4 insertions(+), 4 deletions(-) rename src/domain/usecases/{GetAllOrgUnits.ts => GetAllOrgUnitsUseCase.ts} (90%) rename src/domain/usecases/{GetDiseasesTotalUseCase.ts => GetTotalCardCountsUseCase.ts} (100%) diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 29931856..39bed03e 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -31,16 +31,16 @@ import { MapConfigD2Repository } from "./data/repositories/MapConfigD2Repository import { MapConfigTestRepository } from "./data/repositories/test/MapConfigTestRepository"; import { GetMapConfigUseCase } from "./domain/usecases/GetMapConfigUseCase"; import { GetProvincesOrgUnits } from "./domain/usecases/GetProvincesOrgUnits"; -import { GetAllOrgUnits } from "./domain/usecases/GetAllOrgUnits"; +import { GetAllOrgUnitsUseCase } from "./domain/usecases/GetAllOrgUnitsUseCase"; import { PerformanceOverviewRepository } from "./domain/repositories/PerformanceOverviewRepository"; import { GetAllPerformanceOverviewMetricsUseCase } from "./domain/usecases/GetAllPerformanceOverviewMetricsUseCase"; import { PerformanceOverviewD2Repository } from "./data/repositories/PerformanceOverviewD2Repository"; import { PerformanceOverviewTestRepository } from "./data/repositories/test/PerformanceOverviewTestRepository"; -import { GetTotalCardCountsUseCase } from "./domain/usecases/GetDiseasesTotalUseCase"; import { AlertSyncDataStoreRepository } from "./data/repositories/AlertSyncDataStoreRepository"; import { AlertSyncDataStoreTestRepository } from "./data/repositories/test/AlertSyncDataStoreTestRepository"; import { AlertSyncRepository } from "./domain/repositories/AlertSyncRepository"; import { DataStoreClient } from "./data/DataStoreClient"; +import { GetTotalCardCountsUseCase } from "./domain/usecases/GetTotalCardCountsUseCase"; export type CompositionRoot = ReturnType; @@ -86,7 +86,7 @@ function getCompositionRoot(repositories: Repositories) { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), }, orgUnits: { - getAll: new GetAllOrgUnits(repositories.orgUnitRepository), + getAll: new GetAllOrgUnitsUseCase(repositories.orgUnitRepository), getProvinces: new GetProvincesOrgUnits(repositories.orgUnitRepository), }, }; diff --git a/src/domain/usecases/GetAllOrgUnits.ts b/src/domain/usecases/GetAllOrgUnitsUseCase.ts similarity index 90% rename from src/domain/usecases/GetAllOrgUnits.ts rename to src/domain/usecases/GetAllOrgUnitsUseCase.ts index ff1f2965..fcb25711 100644 --- a/src/domain/usecases/GetAllOrgUnits.ts +++ b/src/domain/usecases/GetAllOrgUnitsUseCase.ts @@ -2,7 +2,7 @@ import { FutureData } from "../../data/api-futures"; import { OrgUnit } from "../entities/OrgUnit"; import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; -export class GetAllOrgUnits { +export class GetAllOrgUnitsUseCase { constructor(private orgUnitRepository: OrgUnitRepository) {} public execute(): FutureData { diff --git a/src/domain/usecases/GetDiseasesTotalUseCase.ts b/src/domain/usecases/GetTotalCardCountsUseCase.ts similarity index 100% rename from src/domain/usecases/GetDiseasesTotalUseCase.ts rename to src/domain/usecases/GetTotalCardCountsUseCase.ts From 66217bb66c934a239c935b1867c522c466f82328 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 2 Oct 2024 11:48:10 +0200 Subject: [PATCH 28/29] Fix responsive in Dashboard, fix DateRangePicker component, fix reset dates and reset also map data --- .../PerformanceOverviewD2Repository.ts | 20 ++-- .../consts/PerformanceOverviewConstants.ts | 2 +- .../PerformanceOverviewRepository.ts | 3 +- .../usecases/GetTotalCardCountsUseCase.ts | 6 +- .../components/date-picker/DatePicker.tsx | 6 + .../date-picker/DateRangePicker.tsx | 52 ++++----- src/webapp/components/map/MapSection.tsx | 18 ++- src/webapp/components/map/useMap.ts | 40 +++---- .../components/selector/MultipleSelector.tsx | 15 ++- src/webapp/pages/dashboard/DashboardPage.tsx | 108 +++++++++++------- .../useAlertsActiveVerifiedFilters.ts | 40 +++++-- src/webapp/pages/dashboard/useCardCounts.ts | 7 +- src/webapp/pages/dashboard/useFilters.ts | 76 ------------ .../pages/event-tracker/EventTrackerPage.tsx | 13 +-- .../pages/event-tracker/useMapFilters.ts | 18 +-- 15 files changed, 212 insertions(+), 212 deletions(-) delete mode 100644 src/webapp/pages/dashboard/useFilters.ts diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index 4987a874..44243d8d 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -5,7 +5,10 @@ import { apiToFuture, FutureData } from "../api-futures"; import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import _ from "../../domain/entities/generic/Collection"; import { Future } from "../../domain/entities/generic/Future"; -import { evenTrackerCountsIndicatorMap, IndicatorsId } from "./consts/PerformanceOverviewConstants"; +import { + eventTrackerCountsIndicatorMap, + IndicatorsId, +} from "./consts/PerformanceOverviewConstants"; import moment from "moment"; import { DiseaseOutbreakEventBaseAttrs, @@ -38,12 +41,13 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos getTotalCardCounts( allProvincesIds: string[], singleSelectFilters?: Record, - multiSelectFilters?: Record + multiSelectFilters?: Record, + dateRangeFilter?: string[] ): FutureData { return apiToFuture( this.api.analytics.get({ dimension: [ - `dx:${evenTrackerCountsIndicatorMap.map(({ id }) => id).join(";")}`, + `dx:${eventTrackerCountsIndicatorMap.map(({ id }) => id).join(";")}`, `ou:${ multiSelectFilters && multiSelectFilters?.province?.length ? multiSelectFilters.province.join(";") @@ -51,12 +55,12 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos }`, ], startDate: - multiSelectFilters?.duration?.length && multiSelectFilters?.duration[0] - ? multiSelectFilters?.duration[0] + dateRangeFilter?.length && dateRangeFilter[0] + ? dateRangeFilter[0] : DEFAULT_START_DATE, endDate: - multiSelectFilters?.duration?.length && multiSelectFilters?.duration[1] - ? multiSelectFilters?.duration[1] + dateRangeFilter?.length && dateRangeFilter[1] + ? dateRangeFilter[1] : DEFAULT_END_DATE, includeMetadataDetails: true, }) @@ -88,7 +92,7 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos ): TotalCardCounts[] => { const counts: TotalCardCounts[] = _( rowData.map(([id, _orgUnit, total]) => { - const indicator = evenTrackerCountsIndicatorMap.find(d => d.id === id); + const indicator = eventTrackerCountsIndicatorMap.find(d => d.id === id); if (!indicator || !total) { return null; } diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index 85ee59dd..8cf8049c 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -190,7 +190,7 @@ export type EventTrackerCountIndicator = | EventTrackerCountDiseaseIndicator | EventTrackerCountHazardIndicator; -export const evenTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ +export const eventTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ { id: "SGGbbu0AKUv", type: "disease", name: "Acute respiratory", incidentStatus: "Watch" }, { id: "QnhsQnEsp1p", type: "disease", name: "Acute respiratory", incidentStatus: "Alert" }, { id: "Rt5KNVqBEO7", type: "disease", name: "Acute respiratory", incidentStatus: "Respond" }, diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index 0d4e904c..174fc4a3 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -12,6 +12,7 @@ export interface PerformanceOverviewRepository { getTotalCardCounts( allProvincesIds: string[], singleSelectFilters?: Record, - multiSelectFilters?: Record + multiSelectFilters?: Record, + dateRangeFilter?: string[] ): FutureData; } diff --git a/src/domain/usecases/GetTotalCardCountsUseCase.ts b/src/domain/usecases/GetTotalCardCountsUseCase.ts index c429004c..939700ff 100644 --- a/src/domain/usecases/GetTotalCardCountsUseCase.ts +++ b/src/domain/usecases/GetTotalCardCountsUseCase.ts @@ -12,14 +12,16 @@ export class GetTotalCardCountsUseCase { ) {} public execute( singleSelectFilters?: Record, - multiSelectFilters?: Record + multiSelectFilters?: Record, + dateRangeFilter?: string[] ): FutureData { return this.options.orgUnitRepository.getByLevel(2).flatMap(allProvinces => { const allProvincesIds = allProvinces.map(province => province.id); return this.options.performanceOverviewRepository.getTotalCardCounts( allProvincesIds, singleSelectFilters, - multiSelectFilters + multiSelectFilters, + dateRangeFilter ); }); } diff --git a/src/webapp/components/date-picker/DatePicker.tsx b/src/webapp/components/date-picker/DatePicker.tsx index 0a1e995e..2bc295f1 100644 --- a/src/webapp/components/date-picker/DatePicker.tsx +++ b/src/webapp/components/date-picker/DatePicker.tsx @@ -16,6 +16,8 @@ type DatePickerProps = { disabled?: boolean; error?: boolean; required?: boolean; + disableFuture?: boolean; + maxDate?: Date; }; const slots = { openPickerIcon: IconCalendar24 }; @@ -31,6 +33,8 @@ export const DatePicker: React.FC = React.memo( errorText = "", error = false, required = false, + disableFuture = false, + maxDate, }) => { const notifyChange = useCallback( (date: Date | null) => { @@ -71,6 +75,8 @@ export const DatePicker: React.FC = React.memo( error={error} disabled={disabled} slotProps={slotProps} + disableFuture={disableFuture} + maxDate={maxDate} /> diff --git a/src/webapp/components/date-picker/DateRangePicker.tsx b/src/webapp/components/date-picker/DateRangePicker.tsx index 253b6745..a28404ba 100644 --- a/src/webapp/components/date-picker/DateRangePicker.tsx +++ b/src/webapp/components/date-picker/DateRangePicker.tsx @@ -1,5 +1,5 @@ import i18n from "../../../utils/i18n"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useMemo, useCallback } from "react"; import { Popover, InputAdornment, TextField, InputLabel } from "@material-ui/core"; import moment from "moment"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; @@ -16,34 +16,29 @@ type DateRangePickerProps = { placeholder?: string; }; +const ID = "date-range-picker"; + export const DateRangePicker: React.FC = React.memo( ({ label = "", value, placeholder = "", onChange }) => { const [anchorEl, setAnchorEl] = useState(null); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); - const id = "date-range-picker"; - - useEffect(() => { - if (!value || value.length !== 2) { - setStartDate(moment().startOf("month").toDate()); - setEndDate(moment().toDate()); - } - }, [value]); - // Adjust startDate if endDate < startDate - useEffect(() => { - if (endDate && startDate && moment(endDate).isBefore(startDate)) { - setStartDate(endDate); - } - }, [startDate, endDate]); - - const handleOpen = (event: React.MouseEvent) => { + const handleOpen = useCallback((event: React.MouseEvent) => { setAnchorEl(event.currentTarget); - }; + }, []); - const handleClose = () => { + const onCleanValues = useCallback(() => { + setStartDate(null); + setEndDate(null); + }, []); + + const handleClose = useCallback(() => { setAnchorEl(null); - }; + if (!value.length) { + onCleanValues(); + } + }, [onCleanValues, value.length]); const formatDurationValue = useMemo(() => { if (!value || value.length !== 2) { @@ -55,12 +50,13 @@ export const DateRangePicker: React.FC = React.memo( )}`; }, [startDate, endDate, placeholder, value]); - const onReset = () => { + const onReset = useCallback(() => { onChange([]); + onCleanValues(); setAnchorEl(null); - }; + }, [onChange, onCleanValues]); - const onSave = () => { + const onSave = useCallback(() => { if (startDate && endDate) { setAnchorEl(null); onChange([ @@ -68,14 +64,14 @@ export const DateRangePicker: React.FC = React.memo( moment(endDate).format("YYYY-MM-DD"), ]); } - }; + }, [endDate, onChange, startDate]); return ( - {label && } + {label && } = React.memo( label="Start Date" value={startDate} onChange={date => setStartDate(date)} + maxDate={endDate ?? undefined} + disableFuture /> setEndDate(date)} + disableFuture /> @@ -140,6 +139,7 @@ const PopoverContainer = styled.div` const Container = styled.div` width: 100%; display: flex; + gap: 5px; justify-content: space-between; `; diff --git a/src/webapp/components/map/MapSection.tsx b/src/webapp/components/map/MapSection.tsx index 1edcdccb..ebf58741 100644 --- a/src/webapp/components/map/MapSection.tsx +++ b/src/webapp/components/map/MapSection.tsx @@ -12,13 +12,20 @@ type MapSectionProps = { mapKey: MapKey; singleSelectFilters?: Record; multiSelectFilters?: Record; + dateRangeFilter?: string[]; eventDiseaseCode?: string; eventHazardCode?: string; }; export const MapSection: React.FC = React.memo(props => { - const { mapKey, singleSelectFilters, multiSelectFilters, eventDiseaseCode, eventHazardCode } = - props; + const { + mapKey, + singleSelectFilters, + multiSelectFilters, + dateRangeFilter, + eventDiseaseCode, + eventHazardCode, + } = props; const { orgUnits } = useAppContext(); const snackbar = useSnackbar(); @@ -32,6 +39,7 @@ export const MapSection: React.FC = React.memo(props => { allOrgUnitsIds: allProvincesIds, singleSelectFilters: singleSelectFilters, multiSelectFilters: multiSelectFilters, + dateRangeFilter: dateRangeFilter, eventDiseaseCode: eventDiseaseCode, eventHazardCode: eventHazardCode, }); @@ -53,9 +61,9 @@ export const MapSection: React.FC = React.memo(props => { > {mapConfigState.kind === "loaded" && allProvincesIds.length !== 0 ? ( ) : null} diff --git a/src/webapp/components/map/useMap.ts b/src/webapp/components/map/useMap.ts index c8e11306..952506ae 100644 --- a/src/webapp/components/map/useMap.ts +++ b/src/webapp/components/map/useMap.ts @@ -49,6 +49,7 @@ export function useMap(params: { allOrgUnitsIds: string[]; eventDiseaseCode?: string; eventHazardCode?: string; + dateRangeFilter?: string[]; singleSelectFilters?: Record; multiSelectFilters?: Record; }): MapState { @@ -57,6 +58,7 @@ export function useMap(params: { allOrgUnitsIds, eventDiseaseCode, eventHazardCode, + dateRangeFilter, singleSelectFilters, multiSelectFilters, } = params; @@ -65,23 +67,16 @@ export function useMap(params: { const [mapConfigState, setMapConfigState] = useState({ kind: "loading", }); + const [defaultStartDate, setDefaultStartDate] = useState(""); useEffect(() => { if (mapConfigState.kind !== "loaded") return; const newStartDate = - multiSelectFilters?.duration?.length && - multiSelectFilters.duration[0] && - multiSelectFilters.duration[0] !== mapConfigState.data.startDate - ? multiSelectFilters.duration[0] - : null; + dateRangeFilter?.length && dateRangeFilter[0] ? dateRangeFilter[0] : defaultStartDate; const newEndDate = - multiSelectFilters?.duration?.length && - multiSelectFilters.duration[1] && - multiSelectFilters.duration[1] !== mapConfigState.data.endDate - ? multiSelectFilters.duration[1] - : null; + dateRangeFilter?.length && dateRangeFilter[1] ? dateRangeFilter[1] : undefined; const isDashboardMapAndThereAreFilters = mapKey === "dashboard" && @@ -117,7 +112,12 @@ export function useMap(params: { ? allOrgUnitsIds : null; - if (!newMapIndicator && !newOrgUnits && !newStartDate && !newEndDate) { + if ( + !newMapIndicator && + !newOrgUnits && + newStartDate === mapConfigState.data.startDate && + newEndDate === mapConfigState.data.endDate + ) { return; } else { setMapConfigState(prevMapConfigState => { @@ -135,10 +135,8 @@ export function useMap(params: { orgUnits: newOrgUnits ? newOrgUnits : prevMapConfigState.data.orgUnits, - startDate: newStartDate - ? newStartDate - : prevMapConfigState.data.startDate, - endDate: newEndDate ? newEndDate : prevMapConfigState.data.endDate, + startDate: newStartDate, + endDate: newEndDate, }, }; } else { @@ -151,7 +149,8 @@ export function useMap(params: { if ( mapKey === "event_tracker" && (eventDiseaseCode || eventHazardCode) && - (newStartDate || newEndDate) + (newStartDate !== mapConfigState.data.startDate || + newEndDate !== mapConfigState.data.endDate) ) { setMapConfigState(prevMapConfigState => { if (prevMapConfigState.kind === "loaded") { @@ -159,10 +158,8 @@ export function useMap(params: { kind: "loaded", data: { ...prevMapConfigState.data, - startDate: newStartDate - ? newStartDate - : prevMapConfigState.data.startDate, - endDate: newEndDate ? newEndDate : prevMapConfigState.data.endDate, + startDate: newStartDate, + endDate: newEndDate, }, }; } else { @@ -179,6 +176,8 @@ export function useMap(params: { mapProgramIndicators, multiSelectFilters, singleSelectFilters, + dateRangeFilter, + defaultStartDate, ]); useEffect(() => { @@ -189,6 +188,7 @@ export function useMap(params: { compositionRoot.maps.getConfig.execute(mapKey).run( config => { setMapProgramIndicators(config.programIndicators); + setDefaultStartDate(config.startDate); const mapProgramIndicator = mapKey === "dashboard" diff --git a/src/webapp/components/selector/MultipleSelector.tsx b/src/webapp/components/selector/MultipleSelector.tsx index ad9db1d8..06fc0d6e 100644 --- a/src/webapp/components/selector/MultipleSelector.tsx +++ b/src/webapp/components/selector/MultipleSelector.tsx @@ -72,7 +72,7 @@ export function MultipleSelector({ error={error} renderValue={(selected: unknown) => (selected as Value[])?.length ? ( -
+ {(selected as Value[]).map(value => ( ({ onMouseDown={event => handleDelete(event, value)} /> ))} -
+ ) : ( placeholder ) @@ -130,7 +130,7 @@ const StyledFormHelperText = styled(FormHelperText)<{ error?: boolean }>` `; const StyledSelect = styled(Select)<{ error?: boolean }>` - height: 40px; + min-height: 40px; .MuiOutlinedInput-notchedOutline { border-color: ${props => props.error ? props.theme.palette.common.red600 : props.theme.palette.common.grey500}; @@ -138,7 +138,7 @@ const StyledSelect = styled(Select)<{ error?: boolean }>` .MuiSelect-root { padding-inline-start: 12px; padding-inline-end: 6px; - padding-block: 10px; + padding-block: 1px; &:focus { background-color: ${props => props.theme.palette.common.white}; } @@ -158,3 +158,10 @@ const SelectedChip = styled(Chip)` } } `; + +const SelectedContainer = styled.div` + width: 100%; + display: flex; + flex-wrap: wrap; + gap: 5px; +`; diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 30b29903..cc69015f 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -20,11 +20,12 @@ import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; export const DashboardPage: React.FC = React.memo(() => { const { - filtersConfig, + selectorFiltersConfig, singleSelectFilters, setSingleSelectFilters, multiSelectFilters, setMultiSelectFilters, + dateRangeFilter, } = useAlertsActiveVerifiedFilters(); const { @@ -37,7 +38,11 @@ export const DashboardPage: React.FC = React.memo(() => { editRiskAssessmentColumns, } = usePerformanceOverview(); - const { cardCounts } = useCardCounts(singleSelectFilters, multiSelectFilters); + const { cardCounts } = useCardCounts( + singleSelectFilters, + multiSelectFilters, + dateRangeFilter.value + ); const { goTo } = useRoutes(); const { resetCurrentEventTracker: resetCurrentEventTrackerId } = useCurrentEventTracker(); @@ -55,41 +60,49 @@ export const DashboardPage: React.FC = React.memo(() => { return (
- - {filtersConfig.map(({ id, label, placeholder, options, type }) => - type === "multiselector" ? ( - setMultiSelectFilters(id, values)} - /> - ) : ( - setSingleSelectFilters(id, value)} - allowClear - /> - ) - )} - setMultiSelectFilters("duration", dates)} - placeholder={i18n.t("Select duration")} - label={i18n.t("Duration")} - /> - + + {selectorFiltersConfig.map(({ id, label, placeholder, options, type }) => { + return ( + + {type === "multiselector" ? ( + + setMultiSelectFilters(id, values) + } + /> + ) : ( + + setSingleSelectFilters(id, value) + } + allowClear + /> + )} + + ); + })} + + + + {cardCounts.map((cardCount, index) => ( - { mapKey="dashboard" singleSelectFilters={singleSelectFilters} multiSelectFilters={multiSelectFilters} + dateRangeFilter={dateRangeFilter.value} />
TBD
@@ -127,18 +141,34 @@ export const DashboardPage: React.FC = React.memo(() => { const GridWrapper = styled.div` width: 100%; display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 0.5rem; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +`; + +const StyledStatsCard = styled(StatsCard)` + width: 220px; `; const StatisticTableWrapper = styled.div` display: grid; `; -const Container = styled.div` +const FiltersContainer = styled.div` display: flex; - justify-content: space-between; align-items: center; + flex-wrap: wrap; margin-bottom: 1rem; gap: 1rem; `; + +const FilterContainer = styled.div` + display: flex; + width: 250px; + max-width: 250px; + justify-content: flex-end; + @media (max-width: 700px) { + flex-wrap: wrap; + justify-content: flex-start; + width: 100%; + } +`; diff --git a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts index 644ce5b7..0c28b9f5 100644 --- a/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts +++ b/src/webapp/pages/dashboard/useAlertsActiveVerifiedFilters.ts @@ -3,9 +3,9 @@ import _c from "../../../domain/entities/generic/Collection"; import { useAppContext } from "../../contexts/app-context"; import { OrgUnit } from "../../../domain/entities/OrgUnit"; import { Option } from "../../components/utils/option"; -import { evenTrackerCountsIndicatorMap } from "../../../data/repositories/consts/PerformanceOverviewConstants"; +import { eventTrackerCountsIndicatorMap } from "../../../data/repositories/consts/PerformanceOverviewConstants"; -export type FiltersConfig = { +export type SelectorFiltersConfig = { id: string; label: string; placeholder: string; @@ -13,7 +13,19 @@ export type FiltersConfig = { options: Option[]; }; -export function useAlertsActiveVerifiedFilters() { +type State = { + selectorFiltersConfig: SelectorFiltersConfig[]; + singleSelectFilters: Record; + setSingleSelectFilters: (id: string, value: string) => void; + multiSelectFilters: Record; + setMultiSelectFilters: (id: string, values: string[]) => void; + dateRangeFilter: { + onChange: (value: string[]) => void; + value: string[]; + }; +}; + +export function useAlertsActiveVerifiedFilters(): State { const { compositionRoot } = useAppContext(); const [singleSelectFilters, setSingleSelectsFilters] = useState>({ @@ -23,10 +35,13 @@ export function useAlertsActiveVerifiedFilters() { }); const [multiSelectFilters, setMultiSelectFilters] = useState>({ province: [], - duration: [], }); + const [provincesOptions, setProvincesOptions] = useState([]); - const [filtersConfig, setFiltersConfig] = useState([]); + + const [selectedRangeDateFilter, setSelectedRangeDateFilter] = useState([]); + + const [selectorFiltersConfig, setSelectorFiltersConfig] = useState([]); useEffect(() => { compositionRoot.orgUnits.getProvinces.execute().run( @@ -58,11 +73,11 @@ export function useAlertsActiveVerifiedFilters() { })); }, []); - // Initialize filter options based on diseasesTotal + // Initialize filter options based on eventTrackerCountsIndicatorMap useEffect(() => { - const buildFiltersConfig = (): FiltersConfig[] => { + const buildSelectorFiltersConfig = (): SelectorFiltersConfig[] => { const createOptions = (key: "disease" | "hazard") => - _c(evenTrackerCountsIndicatorMap) + _c(eventTrackerCountsIndicatorMap) .filter(value => value.type === key) .uniqBy(value => value.name) .map(value => ({ @@ -110,15 +125,20 @@ export function useAlertsActiveVerifiedFilters() { }, ]; }; - setFiltersConfig(buildFiltersConfig()); + + setSelectorFiltersConfig(buildSelectorFiltersConfig()); }, [provincesOptions]); return { - filtersConfig, + selectorFiltersConfig, singleSelectFilters, setSingleSelectFilters: handleSetSingleSelectFilters, multiSelectFilters, setMultiSelectFilters: handleSetMultiSelectFilters, + dateRangeFilter: { + onChange: setSelectedRangeDateFilter, + value: selectedRangeDateFilter, + }, }; } diff --git a/src/webapp/pages/dashboard/useCardCounts.ts b/src/webapp/pages/dashboard/useCardCounts.ts index 10d065ca..7ebaef4e 100644 --- a/src/webapp/pages/dashboard/useCardCounts.ts +++ b/src/webapp/pages/dashboard/useCardCounts.ts @@ -7,7 +7,8 @@ export type Order = { name: string; direction: "asc" | "desc" }; export function useCardCounts( singleSelectFilters: Record, - multiSelectFilters: Record + multiSelectFilters: Record, + dateRangeFilter: string[] ) { const { compositionRoot } = useAppContext(); const [cardCounts, setCardCounts] = useState([]); @@ -16,7 +17,7 @@ export function useCardCounts( useEffect(() => { setIsLoading(true); compositionRoot.performanceOverview.getTotalCardCounts - .execute(singleSelectFilters, multiSelectFilters) + .execute(singleSelectFilters, multiSelectFilters, dateRangeFilter) .run( diseasesTotal => { setCardCounts(diseasesTotal); @@ -27,7 +28,7 @@ export function useCardCounts( setIsLoading(false); } ); - }, [compositionRoot, singleSelectFilters, multiSelectFilters]); + }, [compositionRoot, singleSelectFilters, multiSelectFilters, dateRangeFilter]); return { cardCounts, diff --git a/src/webapp/pages/dashboard/useFilters.ts b/src/webapp/pages/dashboard/useFilters.ts deleted file mode 100644 index b801f899..00000000 --- a/src/webapp/pages/dashboard/useFilters.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { FiltersConfig } from "../../components/table/statistic-table/StatisticTable"; -import { evenTrackerCountsIndicatorMap } from "../../../data/repositories/consts/PerformanceOverviewConstants"; -import _c from "../../../domain/entities/generic/Collection"; - -export function useFilters() { - const [filters, setFilters] = useState>({}); - const [filterOptions, setFilterOptions] = useState([]); - - const buildFilterOptions = useCallback((): FiltersConfig[] => { - const createOptions = (key: "disease" | "hazard") => - _c(evenTrackerCountsIndicatorMap) - .filter(value => value.type === key) - .uniqBy(value => value.name) - .map(value => ({ - value: value.name, - label: value.name, - })) - - .value(); - - const diseaseOptions = createOptions("disease"); - const hazardOptions = createOptions("hazard"); - - return [ - { - value: "incidentStatus", - label: "Incident Status", - type: "multiselector", - options: [ - { value: "Respond", label: "Respond" }, - { value: "Alert", label: "Alert" }, - { value: "Watch", label: "Watch" }, - ], - }, - { - value: "disease", - label: "Disease", - type: "multiselector", - options: diseaseOptions, - }, - { - value: "hazard", - label: "Hazard Type", - type: "multiselector", - options: hazardOptions, - }, - ]; - }, []); - - const handleSetFilters = useCallback( - (newFilters: Record) => { - setFilters(newFilters); - setFilterOptions( - filterOptions.map(option => ({ - ...option, - disabled: - (newFilters.disease && newFilters.disease.length > 0 - ? option.value === "hazard" - : false) || - (newFilters.hazard && newFilters.hazard.length > 0 - ? option.value === "disease" - : false), - })) - ); - }, - [filterOptions] - ); - - // Initialize filter options based on diseasesTotal - useEffect(() => { - setFilterOptions(buildFilterOptions()); - }, [buildFilterOptions]); - - return { filters, filterOptions, setFilters: handleSetFilters }; -} diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 611ed063..5a32e60f 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -53,7 +53,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { const { changeCurrentEventTracker: changeCurrentEventTrackerId, getCurrentEventTracker } = useCurrentEventTracker(); - const { multiSelectFilters, setMultiSelectFilters } = useMapFilters(); + const { dateRangeFilter } = useMapFilters(); useEffect(() => { if (eventTrackerDetails) changeCurrentEventTrackerId(eventTrackerDetails); @@ -76,13 +76,8 @@ export const EventTrackerPage: React.FC = React.memo(() => { > - setMultiSelectFilters({ - ...multiSelectFilters, - duration: dates, - }) - } + value={dateRangeFilter.value || []} + onChange={dateRangeFilter.onChange} placeholder={i18n.t("Select duration")} label={i18n.t("Duration")} /> @@ -97,7 +92,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { mapKey="event_tracker" eventDiseaseCode={getCurrentEventTracker()?.suspectedDiseaseCode} eventHazardCode={getCurrentEventTracker()?.hazardType} - multiSelectFilters={multiSelectFilters} + dateRangeFilter={dateRangeFilter.value || []} />
diff --git a/src/webapp/pages/event-tracker/useMapFilters.ts b/src/webapp/pages/event-tracker/useMapFilters.ts index c8b97574..54ef3950 100644 --- a/src/webapp/pages/event-tracker/useMapFilters.ts +++ b/src/webapp/pages/event-tracker/useMapFilters.ts @@ -1,17 +1,19 @@ -import { Dispatch, SetStateAction, useState } from "react"; +import { useState } from "react"; export type MapFiltersState = { - multiSelectFilters: Record; - setMultiSelectFilters: Dispatch>>; + dateRangeFilter: { + onChange: (value: string[]) => void; + value: string[]; + }; }; export function useMapFilters(): MapFiltersState { - const [multiSelectFilters, setMultiSelectFilters] = useState>({ - duration: [], - }); + const [selectedRangeDateFilter, setSelectedRangeDateFilter] = useState([]); return { - multiSelectFilters, - setMultiSelectFilters, + dateRangeFilter: { + onChange: setSelectedRangeDateFilter, + value: selectedRangeDateFilter, + }, }; } From 416f097ab4299699251b108497993afd6a070772 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 3 Oct 2024 12:19:05 +0200 Subject: [PATCH 29/29] Delete unused files --- .../GetDiseaseOutbreakWithOptionsUseCase.ts | 93 ------------------- .../form/form-summary/useFormSummary.ts | 93 ------------------- 2 files changed, 186 deletions(-) delete mode 100644 src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts delete mode 100644 src/webapp/components/form/form-summary/useFormSummary.ts diff --git a/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts deleted file mode 100644 index aab268c3..00000000 --- a/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { FutureData } from "../../data/api-futures"; -import { - DataSource, - DiseaseOutbreakEventBaseAttrs, -} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { DiseaseOutbreakEventWithOptions } from "../entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions"; -import { Future } from "../entities/generic/Future"; -import { Id } from "../entities/Ref"; -import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; -import { OptionsRepository } from "../repositories/OptionsRepository"; -import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; - -export class GetDiseaseOutbreakWithOptionsUseCase { - constructor( - private options: { - diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; - optionsRepository: OptionsRepository; - teamMemberRepository: TeamMemberRepository; - } - ) {} - - public execute(id?: Id): FutureData { - if (id) { - return this.options.diseaseOutbreakEventRepository - .get(id) - .flatMap(diseaseOutbreakEventBase => { - return this.getDiseaseOutbreakEventWithOptions(diseaseOutbreakEventBase); - }); - } else { - return this.getDiseaseOutbreakEventWithOptions(); - } - } - - private getDiseaseOutbreakEventWithOptions( - diseaseOutbreakEventBase?: DiseaseOutbreakEventBaseAttrs - ): FutureData { - return Future.joinObj({ - dataSources: this.options.optionsRepository.getDataSources(), - hazardTypes: this.options.optionsRepository.getHazardTypes(), - mainSyndromes: this.options.optionsRepository.getMainSyndromes(), - suspectedDiseases: this.options.optionsRepository.getSuspectedDiseases(), - notificationSources: this.options.optionsRepository.getNotificationSources(), - incidentStatus: this.options.optionsRepository.getIncidentStatus(), - incidentManagers: this.options.teamMemberRepository.getIncidentManagers(), - }).flatMap( - ({ - dataSources, - hazardTypes, - mainSyndromes, - suspectedDiseases, - notificationSources, - incidentStatus, - incidentManagers, - }) => { - const diseaseOutbreakEventWithOptions: DiseaseOutbreakEventWithOptions = { - diseaseOutbreakEvent: diseaseOutbreakEventBase, - options: { - dataSources, - incidentManagers, - hazardTypes, - mainSyndromes, - suspectedDiseases, - notificationSources, - incidentStatus, - }, - // TODO: Get labels from Datastore used in mapEntityToInitialFormState to create initial form state - labels: { - errors: { - field_is_required: "This field is required", - field_is_required_na: "This field is required when not applicable", - }, - }, - // TODO: Get rules from Datastore used in applyRulesInFormState - rules: [ - { - type: "toggleSectionsVisibilityByFieldValue", - fieldId: "dataSource", - fieldValue: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, - sectionIds: ["hazardType_section"], - }, - { - type: "toggleSectionsVisibilityByFieldValue", - fieldId: "dataSource", - fieldValue: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, - sectionIds: ["mainSyndrome_section", "suspectedDisease_section"], - }, - ], - }; - return Future.success(diseaseOutbreakEventWithOptions); - } - ); - } -} diff --git a/src/webapp/components/form/form-summary/useFormSummary.ts b/src/webapp/components/form/form-summary/useFormSummary.ts deleted file mode 100644 index 24732b5b..00000000 --- a/src/webapp/components/form/form-summary/useFormSummary.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { useEffect, useState } from "react"; -import { useAppContext } from "../../../contexts/app-context"; -import { Id } from "../../../../domain/entities/Ref"; -import { - DataSource, - DiseaseOutbreakEvent, -} from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { User } from "../../user-selector/UserSelector"; - -import { Maybe } from "../../../../utils/ts-utils"; -import { - getDateAsLocaleDateTimeString, - getDateAsMonthYearString, -} from "../../../../data/repositories/utils/DateTimeHelper"; -import { mapTeamMemberToUser } from "../../../pages/form-page/mapEntityToFormState"; - -const EventTypeLabel = "Event type"; -const DiseaseLabel = "Disease"; -type LabelWithValue = { - label: string; - value: string; -}; - -type FormSummary = { - subTitle: string; - summary: LabelWithValue[]; - incidentManager: Maybe; - notes: string; -}; -export function useFormSummary(id: Id) { - const { compositionRoot } = useAppContext(); - const [formSummary, setFormSummary] = useState(); - const [summaryError, setSummaryError] = useState(); - - useEffect(() => { - compositionRoot.diseaseOutbreakEvent.get.execute(id).run( - diseaseOutbreakEvent => { - setFormSummary(mapDiseaseOutbreakEventToFormSummary(diseaseOutbreakEvent)); - }, - err => { - console.debug(err); - setSummaryError(`Event tracker with id: ${id} does not exist`); - } - ); - }, [compositionRoot.diseaseOutbreakEvent.get, id]); - - const mapDiseaseOutbreakEventToFormSummary = ( - diseaseOutbreakEvent: DiseaseOutbreakEvent - ): FormSummary => { - const dataSourceLabelValue: LabelWithValue = - diseaseOutbreakEvent.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS - ? { - label: EventTypeLabel, - value: diseaseOutbreakEvent.hazardType ?? "", - } - : { - label: DiseaseLabel, - value: diseaseOutbreakEvent.suspectedDisease?.name ?? "", - }; - return { - subTitle: diseaseOutbreakEvent.name, - summary: [ - { - label: "Last updated", - value: getDateAsLocaleDateTimeString(diseaseOutbreakEvent.lastUpdated), - }, - dataSourceLabelValue, - { - label: "Event ID", - value: diseaseOutbreakEvent.id, - }, - { - label: "Emergence date", - value: getDateAsMonthYearString(diseaseOutbreakEvent.emerged.date), - }, - { - label: "Detection date", - value: getDateAsMonthYearString(diseaseOutbreakEvent.detected.date), - }, - { - label: "Notification date", - value: getDateAsMonthYearString(diseaseOutbreakEvent.notified.date), - }, - ], - incidentManager: diseaseOutbreakEvent.incidentManager - ? mapTeamMemberToUser(diseaseOutbreakEvent.incidentManager) - : undefined, - notes: diseaseOutbreakEvent.notes ?? "", - }; - }; - - return { formSummary, summaryError }; -}