diff --git a/i18n/en.pot b/i18n/en.pot index 348dcb1c..c520909e 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-11-27T21:40:09.970Z\n" -"PO-Revision-Date: 2024-11-27T21:40:09.970Z\n" +"POT-Creation-Date: 2025-01-03T08:28:56.703Z\n" +"PO-Revision-Date: 2025-01-03T08:28:56.703Z\n" msgid "Low" msgstr "" @@ -75,9 +75,6 @@ msgstr "" msgid "Save" msgstr "" -msgid "There is an error in this field" -msgstr "" - msgid "Indicates required" msgstr "" @@ -90,6 +87,9 @@ msgstr "" msgid "Edit Details" msgstr "" +msgid "Edit historical case data" +msgstr "" + msgid "Complete Event" msgstr "" @@ -102,6 +102,36 @@ msgstr "" msgid "Currently assigned:" msgstr "" +msgid "Multiple uploads not allowed, please select one file" +msgstr "" + +msgid "Error uploading file." +msgstr "" + +msgid "Select a file" +msgstr "" + +msgid "Errors in file" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Download empty template" +msgstr "" + +msgid "Download historical data" +msgstr "" + +msgid "Confirm remove the file" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Are you sure you want to remove the file?" +msgstr "" + msgid "Error loading current Incident Management Team" msgstr "" @@ -114,9 +144,6 @@ msgstr "" msgid "Map not found." msgstr "" -msgid "Close" -msgstr "" - msgid "Search" msgstr "" @@ -189,15 +216,35 @@ msgstr "" msgid "N/A" msgstr "" -msgid "Add another" +msgid "" +"In order to add or replace cases, you need to download the current file and " +"add the new ones." msgstr "" -msgid "Form cannot be loaded" +msgid "Please, download the template and add the required data." msgstr "" msgid "Disease Outbreak saved successfully" msgstr "" +msgid "Disease outbreak case data saved successfully" +msgstr "" + +msgid "Warning" +msgstr "" + +msgid "" +"You have uploaded a new data cases file. This action will replace the " +"current data of this disease outbreak event with the data of the file. Are " +"you sure you want to continue?" +msgstr "" + +msgid "Add another" +msgstr "" + +msgid "Form cannot be loaded" +msgstr "" + msgid "Risk Assessment Grading saved successfully" msgstr "" @@ -252,8 +299,15 @@ msgstr "" msgid "Confirm deletion" msgstr "" -msgid "Delete" +msgid "Are you sure you want to delete these team roles?" +msgstr "" + +msgid "Are you sure you want to delete this team role?" msgstr "" msgid "Resources" msgstr "" + +msgctxt "DATA" +msgid "HISTORICAL_CASE" +msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 01086e11..ef8c345e 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-11-27T21:40:09.970Z\n" +"POT-Creation-Date: 2025-01-03T08:28:56.703Z\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,9 +74,6 @@ msgstr "" msgid "Save" msgstr "" -msgid "There is an error in this field" -msgstr "" - msgid "Indicates required" msgstr "" @@ -89,6 +86,9 @@ msgstr "" msgid "Edit Details" msgstr "" +msgid "Edit historical case data" +msgstr "" + msgid "Complete Event" msgstr "" @@ -101,6 +101,36 @@ msgstr "" msgid "Currently assigned:" msgstr "" +msgid "Multiple uploads not allowed, please select one file" +msgstr "" + +msgid "Error uploading file." +msgstr "" + +msgid "Select a file" +msgstr "" + +msgid "Errors in file" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Download empty template" +msgstr "" + +msgid "Download historical data" +msgstr "" + +msgid "Confirm remove the file" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Are you sure you want to remove the file?" +msgstr "" + msgid "Error loading current Incident Management Team" msgstr "" @@ -113,9 +143,6 @@ msgstr "" msgid "Map not found." msgstr "" -msgid "Close" -msgstr "" - msgid "Search" msgstr "" @@ -188,15 +215,35 @@ msgstr "" msgid "N/A" msgstr "" -msgid "Add another" +msgid "" +"In order to add or replace cases, you need to download the current file and " +"add the new ones." msgstr "" -msgid "Form cannot be loaded" +msgid "Please, download the template and add the required data." msgstr "" msgid "Disease Outbreak saved successfully" msgstr "" +msgid "Disease outbreak case data saved successfully" +msgstr "" + +msgid "Warning" +msgstr "" + +msgid "" +"You have uploaded a new data cases file. This action will replace the " +"current data of this disease outbreak event with the data of the file. Are " +"you sure you want to continue?" +msgstr "" + +msgid "Add another" +msgstr "" + +msgid "Form cannot be loaded" +msgstr "" + msgid "Risk Assessment Grading saved successfully" msgstr "" @@ -251,12 +298,19 @@ msgstr "" msgid "Confirm deletion" msgstr "" -msgid "Delete" +msgid "Are you sure you want to delete these team roles?" +msgstr "" + +msgid "Are you sure you want to delete this team role?" msgstr "" msgid "Resources" msgstr "" +msgctxt "DATA" +msgid "HISTORICAL_CASE" +msgstr "" + #~ msgid "Add" #~ msgstr "AƱadir" diff --git a/package.json b/package.json index 3dd331cc..4ae4ae11 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,14 @@ "purify-ts-extra-codec": "0.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.5", "react-router-dom": "5.2.0", "real-cancellable-promise": "^1.1.2", "string-ts": "2.2.0", "styled-components": "5.3.5", "styled-jsx": "3.4.5", "typed-immutable-map": "^0.1.1", + "xlsx": "^0.18.5", "zustand": "^4.3.7" }, "devDependencies": { diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index f54db51d..59707b48 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -34,7 +34,6 @@ 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 { GetAllOrgUnitsUseCase } from "./domain/usecases/GetAllOrgUnitsUseCase"; import { PerformanceOverviewRepository } from "./domain/repositories/PerformanceOverviewRepository"; import { GetAllPerformanceOverviewMetricsUseCase } from "./domain/usecases/GetAllPerformanceOverviewMetricsUseCase"; import { PerformanceOverviewD2Repository } from "./data/repositories/PerformanceOverviewD2Repository"; @@ -68,6 +67,9 @@ import { ConfigurationsRepository } from "./domain/repositories/ConfigurationsRe import { ConfigurationsD2Repository } from "./data/repositories/ConfigurationsD2Repository"; import { ConfigurationsTestRepository } from "./data/repositories/test/ConfigurationsTestRepository"; import { CompleteEventTrackerUseCase } from "./domain/usecases/CompleteEventTrackerUseCase"; +import { CasesFileD2Repository } from "./data/repositories/CasesFileD2Repository"; +import { CasesFileRepository } from "./domain/repositories/CasesFileRepository"; +import { CasesFileTestRepository } from "./data/repositories/test/CasesFileTestRepository"; import { UserGroupD2Repository } from "./data/repositories/UserGroupD2Repository"; import { UserGroupRepository } from "./domain/repositories/UserGroupRepository"; import { UserGroupTestRepository } from "./data/repositories/test/UserGroupTestRepository"; @@ -90,6 +92,7 @@ type Repositories = { chartConfigRepository: ChartConfigRepository; systemRepository: SystemRepository; configurationsRepository: ConfigurationsRepository; + casesFileRepository: CasesFileRepository; userGroupRepository: UserGroupRepository; }; @@ -110,6 +113,7 @@ function getCompositionRoot(repositories: Repositories) { getConfigurations: new GetConfigurationsUseCase( repositories.configurationsRepository, repositories.teamMemberRepository, + repositories.orgUnitRepository, repositories.userGroupRepository ), complete: new CompleteEventTrackerUseCase(repositories), @@ -138,7 +142,6 @@ function getCompositionRoot(repositories: Repositories) { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), }, orgUnits: { - getAll: new GetAllOrgUnitsUseCase(repositories.orgUnitRepository), getProvinces: new GetProvincesOrgUnits(repositories.orgUnitRepository), }, charts: { @@ -165,6 +168,7 @@ export function getWebappCompositionRoot(api: D2Api) { chartConfigRepository: new ChartConfigD2Repository(dataStoreClient), systemRepository: new SystemD2Repository(api), configurationsRepository: new ConfigurationsD2Repository(api), + casesFileRepository: new CasesFileD2Repository(api, dataStoreClient), userGroupRepository: new UserGroupD2Repository(api), }; @@ -188,6 +192,7 @@ export function getTestCompositionRoot() { chartConfigRepository: new ChartConfigTestRepository(), systemRepository: new SystemTestRepository(), configurationsRepository: new ConfigurationsTestRepository(), + casesFileRepository: new CasesFileTestRepository(), userGroupRepository: new UserGroupTestRepository(), }; diff --git a/src/data/repositories/AlertSyncDataStoreRepository.ts b/src/data/repositories/AlertSyncDataStoreRepository.ts index cf054678..082e2b7d 100644 --- a/src/data/repositories/AlertSyncDataStoreRepository.ts +++ b/src/data/repositories/AlertSyncDataStoreRepository.ts @@ -6,10 +6,13 @@ import { AlertSyncRepository, } from "../../domain/repositories/AlertSyncRepository"; import { apiToFuture, FutureData } from "../api-futures"; -import { getOutbreakKey, getAlertValueFromMap } from "./utils/AlertOutbreakMapper"; +import { 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 { + AlertsAndCaseForCasesData, + getOutbreakKey, +} from "../../domain/entities/AlertsAndCaseForCasesData"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { RTSL_ZEBRA_ALERTS_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import { assertOrError } from "./utils/AssertOrError"; @@ -56,12 +59,16 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ); return this.getAlertObject(outbreakKey).flatMap(outbreakData => { - const syncData: AlertSynchronizationData = !outbreakData + const syncData: AlertsAndCaseForCasesData = !outbreakData ? synchronizationData : { ...outbreakData, lastSyncTime: new Date().toISOString(), - alerts: [...outbreakData.alerts, ...synchronizationData.alerts], + lastUpdated: new Date().toISOString(), + alerts: [ + ...(outbreakData.alerts || []), + ...(synchronizationData.alerts || []), + ], }; return this.saveAlertObject(outbreakKey, syncData); @@ -88,22 +95,22 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ).flatMap(response => assertOrError(response.instances[0], "Tracked entity")); } - private getAlertObject(outbreakKey: string): FutureData> { - return this.dataStoreClient.getObject(outbreakKey); + private getAlertObject(outbreakKey: string): FutureData> { + return this.dataStoreClient.getObject(outbreakKey); } private saveAlertObject( outbreakKey: string, - syncData: AlertSynchronizationData + syncData: AlertsAndCaseForCasesData ): FutureData { - return this.dataStoreClient.saveObject(outbreakKey, syncData); + return this.dataStoreClient.saveObject(outbreakKey, syncData); } private buildSynchronizationData( options: AlertSyncOptions, trackedEntity: D2TrackerTrackedEntity, outbreakKey: string - ): AlertSynchronizationData { + ): AlertsAndCaseForCasesData { const { alert, nationalDiseaseOutbreakEventId, dataSource } = options; const outbreakType = dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS ? "disease" : "hazard"; @@ -128,7 +135,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { return { lastSyncTime: new Date().toISOString(), - type: outbreakType, + lastUpdated: new Date().toISOString(), nationalDiseaseOutbreakEventId: nationalDiseaseOutbreakEventId, [outbreakType]: outbreakKey, alerts: alerts, diff --git a/src/data/repositories/CasesFileD2Repository.ts b/src/data/repositories/CasesFileD2Repository.ts new file mode 100644 index 00000000..7a394733 --- /dev/null +++ b/src/data/repositories/CasesFileD2Repository.ts @@ -0,0 +1,187 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { DataStoreClient } from "../DataStoreClient"; +import { apiToFuture, FutureData } from "../api-futures"; +import { CasesFileRepository } from "../../domain/repositories/CasesFileRepository"; +import { AlertsAndCaseForCasesData } from "../../domain/entities/AlertsAndCaseForCasesData"; +import { Maybe } from "../../utils/ts-utils"; +import { Id } from "../../domain/entities/Ref"; +import { Future } from "../../domain/entities/generic/Future"; +import { CaseFile } from "../../domain/entities/CasesFile"; +import { AppDatastoreConfig } from "../../domain/entities/AppDatastoreConfig"; + +export class CasesFileD2Repository implements CasesFileRepository { + constructor(private api: D2Api, private dataStoreClient: DataStoreClient) {} + + get(outbreakKey: string): FutureData { + return this.getAlertsAndCaseForCasesDataObject(outbreakKey).flatMap( + alertsAndCaseForCasesData => { + if ( + !alertsAndCaseForCasesData?.case?.fileId || + !alertsAndCaseForCasesData?.case?.fileName || + !alertsAndCaseForCasesData?.case?.fileType + ) + return Future.error(new Error("No cases file id found")); + + return this.downloadCasesFile( + alertsAndCaseForCasesData.case.fileId, + alertsAndCaseForCasesData.case.fileName, + alertsAndCaseForCasesData.case.fileType + ); + } + ); + } + + getTemplate(): FutureData { + return this.dataStoreClient + .getObject("app-config") + .flatMap(appConfig => { + if ( + !appConfig?.casesFileTemplate?.fileId || + !appConfig?.casesFileTemplate?.fileName + ) + return Future.error(new Error("No cases file template found")); + + const { casesFileTemplate } = appConfig; + return this.downloadCasesFile( + casesFileTemplate.fileId, + casesFileTemplate.fileName, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + }); + } + + save(diseaseOutbreakEventId: Id, outbreakKey: string, caseFile: CaseFile): FutureData { + const renamedUploadedCasesDataFile = new File( + [caseFile.file], + `HISTORICAL_CASE_DATA_${diseaseOutbreakEventId}`, + { + type: caseFile.file.type, + } + ); + + return Future.joinObj({ + fileId: this.uploadCasesFile(renamedUploadedCasesDataFile), + alertsAndCaseForCasesData: this.getAlertsAndCaseForCasesDataObject(outbreakKey), + }).flatMap(({ fileId, alertsAndCaseForCasesData }) => { + const newAlertsAndCaseForCasesData: AlertsAndCaseForCasesData = { + ...(alertsAndCaseForCasesData || {}), + lastSyncTime: alertsAndCaseForCasesData?.lastSyncTime || "", + lastUpdated: new Date().toISOString(), + nationalDiseaseOutbreakEventId: diseaseOutbreakEventId, + case: { + fileId, + fileName: renamedUploadedCasesDataFile.name, + fileType: renamedUploadedCasesDataFile.type, + }, + }; + return this.saveAlertsAndCaseForCasesDataObject( + outbreakKey, + newAlertsAndCaseForCasesData + ).flatMap(() => Future.success(undefined)); + }); + } + + delete(outbreakKey: string): FutureData { + return this.getAlertsAndCaseForCasesDataObject(outbreakKey).flatMap( + alertsAndCaseForCasesData => { + if (!alertsAndCaseForCasesData?.case?.fileId) + return Future.error(new Error("No cases file id found")); + + return this.deleteCasesFile(alertsAndCaseForCasesData.case.fileId).flatMap(() => { + return this.saveAlertsAndCaseForCasesDataObject(outbreakKey, { + ...alertsAndCaseForCasesData, + lastUpdated: new Date().toISOString(), + case: undefined, + }); + }); + } + ); + } + + private downloadCasesFile( + fileId: Id, + fileName: string, + fileType: string + ): FutureData { + return apiToFuture(this.api.files.get(fileId)) + .map(blob => { + return new File([blob], fileName, { type: fileType }); + }) + .flatMap(file => { + return Future.success({ + fileId, + file, + }); + }); + } + + private uploadCasesFile(file: File): FutureData { + return apiToFuture( + this.api.files.upload({ + name: file.name, + data: file, + }) + ).flatMap(response => { + return this.dataStoreClient + .getObject("app-config") + .flatMap(appConfig => { + const captureAccessUserGroups = [ + ...(appConfig?.userGroups.admin || []), + ...(appConfig?.userGroups.capture || []), + ].map(userGroupId => ({ + access: "rw------", + id: userGroupId, + })); + + const visualizerAccessUserGroups = [ + ...(appConfig?.userGroups.visualizer || []), + ].map(userGroupId => ({ + access: "r-------", + id: userGroupId, + })); + + return apiToFuture( + this.api.sharing.post( + { + id: response.id, + type: "document", + }, + { + externalAccess: false, + publicAccess: "--------", + userGroupAccesses: [ + ...captureAccessUserGroups, + ...visualizerAccessUserGroups, + ], + } + ) + ).flatMap(() => { + return Future.success(response.id); + }); + }); + }); + } + + private deleteCasesFile(fileId: Id): FutureData { + return apiToFuture(this.api.files.delete(fileId)).flatMap(response => { + if (response.httpStatus === "OK") return Future.success(undefined); + else return Future.error(new Error("Error while deleting cases file")); + }); + } + + private getAlertsAndCaseForCasesDataObject( + outbreakKey: string + ): FutureData> { + return this.dataStoreClient.getObject(outbreakKey); + } + + private saveAlertsAndCaseForCasesDataObject( + outbreakKey: string, + alertsAndCaseForCasesData: AlertsAndCaseForCasesData + ): FutureData { + return this.dataStoreClient.saveObject( + outbreakKey, + alertsAndCaseForCasesData + ); + } +} diff --git a/src/data/repositories/ChartConfigD2Repository.ts b/src/data/repositories/ChartConfigD2Repository.ts index 17ac2418..5d3cb2be 100644 --- a/src/data/repositories/ChartConfigD2Repository.ts +++ b/src/data/repositories/ChartConfigD2Repository.ts @@ -2,12 +2,14 @@ import { DataStoreClient } from "../DataStoreClient"; import { FutureData } from "../api-futures"; import { ChartConfigRepository } from "../../domain/repositories/ChartConfigRepository"; import { Id } from "../../domain/entities/Ref"; +import { CasesDataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type ChartConfig = { key: string; casesId: Id; deathsId: Id; riskAssessmentHistoryId: Id; + casesDataSource: CasesDataSource; }; const chartConfigDatastoreKey = "charts-config"; @@ -15,24 +17,29 @@ const chartConfigDatastoreKey = "charts-config"; export class ChartConfigD2Repository implements ChartConfigRepository { constructor(private dataStoreClient: DataStoreClient) {} - public getCases(chartKey: string): FutureData { + public getCases(chartKey: string, casesDataSource: CasesDataSource): FutureData { return this.dataStoreClient .getObject(chartConfigDatastoreKey) .map(chartConfigs => { const currentChart = chartConfigs?.find( - chartConfig => chartConfig.key === chartKey + chartConfig => + chartConfig.key === chartKey && + chartConfig.casesDataSource === casesDataSource ); + if (currentChart) return currentChart.casesId; else throw new Error(`Chart id not found for ${chartKey}`); }); } - public getDeaths(chartKey: string): FutureData { + public getDeaths(chartKey: string, casesDataSource: CasesDataSource): FutureData { return this.dataStoreClient .getObject(chartConfigDatastoreKey) .map(chartConfigs => { const currentChart = chartConfigs?.find( - chartConfig => chartConfig.key === chartKey + chartConfig => + chartConfig.key === chartKey && + chartConfig.casesDataSource === casesDataSource ); if (currentChart) return currentChart.deathsId; else throw new Error(`Chart id not found for ${chartKey}`); diff --git a/src/data/repositories/ConfigurationsD2Repository.ts b/src/data/repositories/ConfigurationsD2Repository.ts index 2282a4b0..186190b1 100644 --- a/src/data/repositories/ConfigurationsD2Repository.ts +++ b/src/data/repositories/ConfigurationsD2Repository.ts @@ -27,6 +27,7 @@ const optionSetCode: Record = { phoecLevel: "RTSL_ZEB_OS_PHOEC_ACT_LEVEL", status: "RTSL_ZEB_OS_STATUS", verification: "RTSL_ZEB_OS_VERIFICATION", + casesDataSource: "RTSL_ZEB_OS_CASE_DATA_SOURCE", }; export class ConfigurationsD2Repository implements ConfigurationsRepository { @@ -198,6 +199,13 @@ export class ConfigurationsD2Repository implements ConfigurationsRepository { if (verifications) selectableOptions.incidentResponseActionConfigurations.verification = this.mapD2OptionSetToOptions(verifications); + } else if (key === "casesDataSource") { + const casesDataSource = optionsResponse.optionSets.find( + optionSet => optionSet.code === value + ); + if (casesDataSource) + selectableOptions.eventTrackerConfigurations.casesDataSource = + this.mapD2OptionSetToOptions(casesDataSource); } }); @@ -215,6 +223,7 @@ export class ConfigurationsD2Repository implements ConfigurationsRepository { notificationSources: [], incidentStatus: [], incidentManagers: [], + casesDataSource: [], }, riskAssessmentGradingConfigurations: { populationAtRisk: [], diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index a9688a2b..3ff2b652 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -1,7 +1,11 @@ import { D2Api } from "../../types/d2-api"; import { DiseaseOutbreakEventRepository } from "../../domain/repositories/DiseaseOutbreakEventRepository"; import { apiToFuture, FutureData } from "../api-futures"; -import { DiseaseOutbreakEventBaseAttrs } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CaseData, + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../../domain/entities/Ref"; import { mapDiseaseOutbreakEventToTrackedEntityAttributes, @@ -9,11 +13,25 @@ import { } from "./utils/DiseaseOutbreakMapper"; import { RTSL_ZEBRA_ORG_UNIT_ID, RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { getProgramTEAsMetadata } from "./utils/MetadataHelper"; +import { + D2ProgramStageDataElement, + getProgramDataElementsMetadata, + getProgramTEAsMetadata, +} from "./utils/MetadataHelper"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; import { D2TrackerEnrollment } from "@eyeseetea/d2-api/api/trackerEnrollments"; +import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; +import { + CasesDataCode, + CasesDataKeyCode, + RTSL_ZEBRA_CASE_PROGRAM_ID, + RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID, + RTSL_ZEB_DET_NATIONAL_EVENT_ID_ID, + getCasesDataValuesFromDiseaseOutbreak, + isStringInCasesDataCodes, +} from "./consts/CaseDataConstants"; export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRepository { constructor(private api: D2Api) {} @@ -45,7 +63,9 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep }); } - save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData { + save(diseaseOutbreak: DiseaseOutbreakEvent, haveChangedCasesData?: boolean): FutureData { + const hasNewCasesData = !diseaseOutbreak.id && !!diseaseOutbreak.uploadedCasesData; + return getProgramTEAsMetadata(this.api, RTSL_ZEBRA_PROGRAM_ID).flatMap( teasMetadataResponse => { const teasMetadata = @@ -78,7 +98,26 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep ) ); } else { - return Future.success(diseaseOutbreakId); + const diseaseOutbreakWithId = new DiseaseOutbreakEvent({ + ...diseaseOutbreak, + id: diseaseOutbreakId, + }); + if (hasNewCasesData || haveChangedCasesData) { + if (haveChangedCasesData) { + // NOTICE: If the cases data has changed, we need to replace the old one with the new one + return this.deleteCasesData(diseaseOutbreakWithId).flatMap(() => { + return this.saveCasesData(diseaseOutbreakWithId).flatMap(() => + Future.success(diseaseOutbreakId) + ); + }); + } else { + return this.saveCasesData(diseaseOutbreakWithId).flatMap(() => + Future.success(diseaseOutbreakId) + ); + } + } else { + return Future.success(diseaseOutbreakId); + } } }); } @@ -120,10 +159,152 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep return Future.error( new Error(`Error completing disease outbreak event : ${response.message}`) ); + } else { + return this.markCasesDataAsCompleted(id).flatMap(() => + Future.success(undefined) + ); + } + }); + }); + } + + private markCasesDataAsCompleted(diseaseOutbreakId: Id): FutureData { + return this.getd2EventCasesDataByDiseaseOutbreakId(diseaseOutbreakId).flatMap(d2Events => { + if (!d2Events.length) { + return Future.success(undefined); + } + + const d2CompletedEvents = d2Events.map( + (d2Event: D2TrackerEvent): D2TrackerEvent => ({ + ...d2Event, + status: "COMPLETED", + }) + ); + return apiToFuture( + this.api.tracker.post({ importStrategy: "UPDATE" }, { events: d2CompletedEvents }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error( + `Error while marking the cases data as completed: ${response.message}` + ) + ); + } else return Future.success(undefined); + }); + }); + } + + private getd2EventCasesDataByDiseaseOutbreakId( + diseaseOutbreakId: Id + ): FutureData { + return apiToFuture( + this.api.tracker.events.get({ + program: RTSL_ZEBRA_CASE_PROGRAM_ID, + programStage: RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID, + fields: { + program: true, + orgUnit: true, + dataValues: { + dataElement: { id: true, code: true }, + value: true, + }, + event: true, + occurredAt: true, + status: true, + }, + filter: `${RTSL_ZEB_DET_NATIONAL_EVENT_ID_ID}:eq:${diseaseOutbreakId}`, + }) + ) + .flatMap(response => + assertOrError( + response.instances, + `Error fetching cases data for disease outbreak ${diseaseOutbreakId}` + ) + ) + .flatMap(d2Events => { + return Future.success(d2Events); + }); + } + + private saveCasesData(diseaseOutbreak: DiseaseOutbreakEvent): FutureData { + return getProgramDataElementsMetadata(this.api, RTSL_ZEBRA_CASE_PROGRAM_ID) + .flatMap(response => + assertOrError(response.objects[0], `Case program metadata not found`) + ) + .flatMap(programDataElementsMetadataResponse => { + const programDataElements: D2ProgramStageDataElement[] | undefined = + programDataElementsMetadataResponse.programStages.find( + programStage => programStage.id === RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID + )?.programStageDataElements; + + if (!diseaseOutbreak.uploadedCasesData || !programDataElements) + return Future.error( + new Error(`Cases data or case program data elements not found.`) + ); + + const d2TrackerEvents = diseaseOutbreak.uploadedCasesData.map(caseData => + this.mapCaseDataToD2TrackerEvents( + caseData, + diseaseOutbreak, + programDataElements + ) + ); + + return apiToFuture( + this.api.tracker.post({ importStrategy: "CREATE" }, { events: d2TrackerEvents }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error(`Error saving cases data: ${response.message}`) + ); + } else return Future.success(undefined); + }); + }); + } + + private deleteCasesData(diseaseOutbreak: DiseaseOutbreakEvent): FutureData { + return this.getd2EventCasesDataByDiseaseOutbreakId(diseaseOutbreak.id).flatMap(d2Events => { + return apiToFuture( + this.api.tracker.post({ importStrategy: "DELETE" }, { events: d2Events }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error(`Error deleting cases data: ${response.message}`) + ); } else return Future.success(undefined); }); }); } + private mapCaseDataToD2TrackerEvents( + caseData: CaseData, + diseaseOutbreak: DiseaseOutbreakEvent, + programDataElements: D2ProgramStageDataElement[] + ): D2TrackerEvent { + const casesDataValuesByCode: Record = + getCasesDataValuesFromDiseaseOutbreak(caseData, diseaseOutbreak); + + const dataValues = programDataElements.map(({ dataElement }) => { + if (!isStringInCasesDataCodes(dataElement.code)) { + throw new Error("Data element code not found in cases data"); + } + const typedCode: CasesDataKeyCode = dataElement.code; + return { + dataElement: dataElement.id, + value: casesDataValuesByCode[typedCode], + }; + }); + + return { + event: "", + program: RTSL_ZEBRA_CASE_PROGRAM_ID, + programStage: RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID, + orgUnit: caseData.orgUnit, + occurredAt: caseData.reportDate, + status: "ACTIVE", + dataValues, + }; + } + //TO DO : Implement delete/archive after requirement confirmation } diff --git a/src/data/repositories/MapConfigD2Repository.ts b/src/data/repositories/MapConfigD2Repository.ts index 94b383c4..79479363 100644 --- a/src/data/repositories/MapConfigD2Repository.ts +++ b/src/data/repositories/MapConfigD2Repository.ts @@ -1,3 +1,4 @@ +import { CasesDataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../domain/entities/generic/Future"; import { MapKey, @@ -24,11 +25,14 @@ export class MapConfigD2Repository implements MapConfigRepository { this.dataStoreClient = new DataStoreClient(api); } - public get(mapKey: MapKey): FutureData { + public get(mapKey: MapKey, casesDataSource?: CasesDataSource): FutureData { const programIndicatorsDatastoreKey = mapKey === "dashboard" ? ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts - : ProgramIndicatorsDatastoreKey.CasesAlerts; + : casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ? ProgramIndicatorsDatastoreKey.SuspectedCasesCasesProgram + : ProgramIndicatorsDatastoreKey.SuspectedCasesAlertsProgram; + return this.dataStoreClient .getObject(MAPS_CONFIG_KEY) .flatMap(mapsConfigDatastore => { @@ -42,7 +46,10 @@ export class MapConfigD2Repository implements MapConfigRepository { const mapConfigDataStore = mapKey === "dashboard" ? mapsConfigDatastore?.dashboard - : mapsConfigDatastore?.event_tracker; + : casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ? mapsConfigDatastore?.event_tracker_cases + : mapsConfigDatastore?.event_tracker_alerts; + return getProgramIndicatorsFromDatastore( this.dataStoreClient, programIndicatorsDatastoreKey @@ -82,7 +89,8 @@ export class MapConfigD2Repository implements MapConfigRepository { type MapsConfigDatastore = { dashboard: MapConfigDatastore; - event_tracker: MapConfigDatastore; + event_tracker_alerts: MapConfigDatastore; + event_tracker_cases: MapConfigDatastore; }; type MapConfigDatastore = { diff --git a/src/data/repositories/OrgUnitD2Repository.ts b/src/data/repositories/OrgUnitD2Repository.ts index bb325f02..9e662355 100644 --- a/src/data/repositories/OrgUnitD2Repository.ts +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -1,9 +1,14 @@ import { D2Api, MetadataPick } from "../../types/d2-api"; -import { OrgUnit } from "../../domain/entities/OrgUnit"; +import { OrgUnit, OrgUnitLevelType } from "../../domain/entities/OrgUnit"; import { Id } from "../../domain/entities/Ref"; import { OrgUnitRepository } from "../../domain/repositories/OrgUnitRepository"; import { apiToFuture, FutureData } from "../api-futures"; +const orgUnitLevelTypeByLevelNumber: Record = { + 1: "National", + 2: "Province", + 3: "District", +}; export class OrgUnitD2Repository implements OrgUnitRepository { constructor(private api: D2Api) {} @@ -12,7 +17,7 @@ export class OrgUnitD2Repository implements OrgUnitRepository { this.api.metadata.get({ organisationUnits: { fields: d2OrgUnitFields, - filter: { level: { in: ["2", "3"] } }, + filter: { level: { in: ["1", "2", "3"] } }, }, }) ).map(response => { @@ -46,14 +51,18 @@ export class OrgUnitD2Repository implements OrgUnitRepository { } private mapD2OrgUnitsToOrgUnits(d2OrgUnit: D2OrgUnit[]): OrgUnit[] { - return d2OrgUnit.map( - (ou): OrgUnit => ({ - id: ou.id, - name: ou.name, - code: ou.code, - level: ou.level === 2 ? "Province" : "District", + return d2OrgUnit + .map(ou => { + if (orgUnitLevelTypeByLevelNumber[ou.level]) { + return { + id: ou.id, + name: ou.name, + code: ou.code, + level: orgUnitLevelTypeByLevelNumber[ou.level], + }; + } }) - ); + .filter((orgUnit): orgUnit is OrgUnit => orgUnit !== undefined); } } diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index 482640da..4f88ea4c 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -6,13 +6,12 @@ import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import _ from "../../domain/entities/generic/Collection"; import { Future } from "../../domain/entities/generic/Future"; import { - PERFORMANCE_METRICS_717_IDS, - IndicatorsId, - EVENT_TRACKER_717_IDS, EventTrackerCountIndicator, + PerformanceOverviewDimensions, } from "./consts/PerformanceOverviewConstants"; import moment from "moment"; import { + CasesDataSource, DiseaseOutbreakEventBaseAttrs, NationalIncidentStatus, } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; @@ -25,7 +24,6 @@ import { PerformanceMetrics717, IncidentStatus, } from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; -import { OrgUnit } from "../../domain/entities/OrgUnit"; import { Id } from "../../domain/entities/Ref"; import { OverviewCard } from "../../domain/entities/PerformanceOverview"; import { assertOrError } from "./utils/AssertOrError"; @@ -44,9 +42,15 @@ const formatDate = (date: Date): string => { const DEFAULT_END_DATE: string = formatDate(new Date()); const DEFAULT_START_DATE = "2000-01-01"; -const EVENT_TRACKER_OVERVIEW_DATASTORE_KEY = "event-tracker-overview-ids"; -type EventTrackerOverview = { +const ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY = + "alerts-program-event-tracker-overview-ids"; +const CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY = + "cases-program-event-tracker-overview-ids"; +const PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY = "717-performance-program-indicators"; +const PERFORMANCE_OVERVIEW_DIMENSIONS_DATASTORE_KEY = "performance-overview-dimensions"; + +type EventTrackerOverviewInDataStore = { key: string; suspectedCasesId: Id; confirmedCasesId: Id; @@ -54,6 +58,10 @@ type EventTrackerOverview = { probableCasesId: Id; }; +type EventTrackerOverview = EventTrackerOverviewInDataStore & { + casesDataSource: CasesDataSource; +}; + type IdValue = { id: Id; value: string; @@ -214,266 +222,347 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos } private getEventTrackerOverviewIdsFromDatastore( - type: string + type: string, + casesDataSource: CasesDataSource ): FutureData { + const datastoreKey = + casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ? CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY + : ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY; + return this.datastore - .getObject(EVENT_TRACKER_OVERVIEW_DATASTORE_KEY) + .getObject(datastoreKey) .flatMap(nullableEventTrackerOverviewIds => { - return assertOrError( - nullableEventTrackerOverviewIds, - EVENT_TRACKER_OVERVIEW_DATASTORE_KEY - ).flatMap(eventTrackerOverviewIds => { - const currentEventTrackerOverviewId = eventTrackerOverviewIds?.find( - indicator => indicator.key === type - ); - - if (!currentEventTrackerOverviewId) - return Future.error( - new Error( - `Event Tracker Overview Ids for type ${type} not found in datastore` - ) + return assertOrError(nullableEventTrackerOverviewIds, datastoreKey).flatMap( + eventTrackerOverviewIds => { + const currentEventTrackerOverviewId = eventTrackerOverviewIds?.find( + indicator => indicator.key === type ); - return Future.success(currentEventTrackerOverviewId); - }); + + if (!currentEventTrackerOverviewId) + return Future.error( + new Error( + `Event Tracker Overview Ids for type ${type} not found in datastore` + ) + ); + return Future.success({ + ...currentEventTrackerOverviewId, + casesDataSource: casesDataSource, + }); + } + ); }); } private getAllEventTrackerOverviewIdsFromDatastore(): FutureData { - return this.datastore - .getObject(EVENT_TRACKER_OVERVIEW_DATASTORE_KEY) - .flatMap(nullableEventTrackerOverviewIds => { + return Future.joinObj({ + alertsEventTrackerOverviewIdsResponse: this.datastore.getObject< + EventTrackerOverviewInDataStore[] + >(ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY), + casesEventTrackerOverviewIdsResponse: this.datastore.getObject< + EventTrackerOverviewInDataStore[] + >(CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY), + }).flatMap( + ({ alertsEventTrackerOverviewIdsResponse, casesEventTrackerOverviewIdsResponse }) => { return assertOrError( - nullableEventTrackerOverviewIds, - EVENT_TRACKER_OVERVIEW_DATASTORE_KEY - ); - }); + alertsEventTrackerOverviewIdsResponse, + ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY + ).flatMap(alertsEventTrackerOverviewIds => { + return assertOrError( + casesEventTrackerOverviewIdsResponse, + CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY + ).flatMap(casesEventTrackerOverviewIds => { + return Future.success([ + ...alertsEventTrackerOverviewIds.map( + ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + }) => ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + casesDataSource: + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, + }) + ), + ...casesEventTrackerOverviewIds.map( + ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + }) => ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + casesDataSource: + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF, + }) + ), + ]); + }); + }); + } + ); } - getEventTrackerOverviewMetrics(type: string): FutureData { - return this.getEventTrackerOverviewIdsFromDatastore(type).flatMap(eventTrackerOverview => { - const { suspectedCasesId, probableCasesId, confirmedCasesId, deathsId } = - eventTrackerOverview; - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(new Date().getDate() - 7); - - return Future.joinObj( - { - cumulativeSuspectedCases: this.getAnalyticsApi( - suspectedCasesId, - DEFAULT_START_DATE - ), - newSuspectedCases: this.getAnalyticsApi( - suspectedCasesId, - formatDate(sevenDaysAgo) - ), - cumulativeProbableCases: this.getAnalyticsApi( - probableCasesId, - DEFAULT_START_DATE - ), - newProbableCases: this.getAnalyticsApi( - probableCasesId, - formatDate(sevenDaysAgo) - ), - cumulativeConfirmedCases: this.getAnalyticsApi( - confirmedCasesId, - DEFAULT_START_DATE - ), - newConfirmedCases: this.getAnalyticsApi( - confirmedCasesId, - formatDate(sevenDaysAgo) - ), - cumulativeDeaths: this.getAnalyticsApi(deathsId, DEFAULT_START_DATE), - newDeaths: this.getAnalyticsApi(deathsId, formatDate(sevenDaysAgo)), - }, - { concurrency: 5 } - ).flatMap( - ({ - cumulativeSuspectedCases, - newSuspectedCases, - cumulativeProbableCases, - newProbableCases, - cumulativeConfirmedCases, - newConfirmedCases, - cumulativeDeaths, - newDeaths, - }) => { - return Future.success([ - { - name: "New Suspected Cases", - value: newSuspectedCases?.rows[0]?.[1] - ? parseInt(newSuspectedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "New Probable Cases", - value: newProbableCases?.rows[0]?.[1] - ? parseInt(newProbableCases?.rows[0]?.[1]) - : 0, - }, - { - name: "New Confirmed Cases", - value: newConfirmedCases?.rows[0]?.[1] - ? parseInt(newConfirmedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "New Deaths", - value: newDeaths?.rows[0]?.[1] ? parseInt(newDeaths?.rows[0]?.[1]) : 0, - }, - { - name: "Cumulative Suspected Cases", - value: cumulativeSuspectedCases?.rows[0]?.[1] - ? parseInt(cumulativeSuspectedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "Cumulative Probable Cases", - value: cumulativeProbableCases?.rows[0]?.[1] - ? parseInt(cumulativeProbableCases?.rows[0]?.[1]) - : 0, - }, - { - name: "Cumulative Confirmed Cases", - value: cumulativeConfirmedCases?.rows[0]?.[1] - ? parseInt(cumulativeConfirmedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "Cumulative Deaths", - value: cumulativeDeaths?.rows[0]?.[1] - ? parseInt(cumulativeDeaths?.rows[0]?.[1]) - : 0, - }, - ]); - } - ); - }); + getEventTrackerOverviewMetrics( + type: string, + casesDataSource: CasesDataSource + ): FutureData { + return this.getEventTrackerOverviewIdsFromDatastore(type, casesDataSource).flatMap( + eventTrackerOverview => { + const { suspectedCasesId, probableCasesId, confirmedCasesId, deathsId } = + eventTrackerOverview; + + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(new Date().getDate() - 7); + + return Future.joinObj( + { + cumulativeSuspectedCases: this.getAnalyticsApi( + suspectedCasesId, + DEFAULT_START_DATE + ), + newSuspectedCases: this.getAnalyticsApi( + suspectedCasesId, + formatDate(sevenDaysAgo) + ), + cumulativeProbableCases: this.getAnalyticsApi( + probableCasesId, + DEFAULT_START_DATE + ), + newProbableCases: this.getAnalyticsApi( + probableCasesId, + formatDate(sevenDaysAgo) + ), + cumulativeConfirmedCases: this.getAnalyticsApi( + confirmedCasesId, + DEFAULT_START_DATE + ), + newConfirmedCases: this.getAnalyticsApi( + confirmedCasesId, + formatDate(sevenDaysAgo) + ), + cumulativeDeaths: this.getAnalyticsApi(deathsId, DEFAULT_START_DATE), + newDeaths: this.getAnalyticsApi(deathsId, formatDate(sevenDaysAgo)), + }, + { concurrency: 5 } + ).flatMap( + ({ + cumulativeSuspectedCases, + newSuspectedCases, + cumulativeProbableCases, + newProbableCases, + cumulativeConfirmedCases, + newConfirmedCases, + cumulativeDeaths, + newDeaths, + }) => { + return Future.success([ + { + name: "New Suspected Cases", + value: newSuspectedCases?.rows[0]?.[1] + ? parseInt(newSuspectedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "New Probable Cases", + value: newProbableCases?.rows[0]?.[1] + ? parseInt(newProbableCases?.rows[0]?.[1]) + : 0, + }, + { + name: "New Confirmed Cases", + value: newConfirmedCases?.rows[0]?.[1] + ? parseInt(newConfirmedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "New Deaths", + value: newDeaths?.rows[0]?.[1] + ? parseInt(newDeaths?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Suspected Cases", + value: cumulativeSuspectedCases?.rows[0]?.[1] + ? parseInt(cumulativeSuspectedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Probable Cases", + value: cumulativeProbableCases?.rows[0]?.[1] + ? parseInt(cumulativeProbableCases?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Confirmed Cases", + value: cumulativeConfirmedCases?.rows[0]?.[1] + ? parseInt(cumulativeConfirmedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Deaths", + value: cumulativeDeaths?.rows[0]?.[1] + ? parseInt(cumulativeDeaths?.rows[0]?.[1]) + : 0, + }, + ]); + } + ); + } + ); } getPerformanceOverviewMetrics( diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] ): FutureData { - return apiToFuture( - this.api.analytics.getEnrollmentsQuery({ - programId: RTSL_ZEBRA_PROGRAM_ID, - 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, - ], - startDate: DEFAULT_START_DATE, - endDate: DEFAULT_END_DATE, - }) - ).flatMap(indicatorsProgramFuture => { - return this.getAllEventTrackerOverviewIdsFromDatastore().flatMap( - eventTrackerOverviews => { - const mappedIndicators = - indicatorsProgramFuture?.rows.map((row: string[]) => - this.mapRowToBaseIndicator( - row, - indicatorsProgramFuture.headers, - indicatorsProgramFuture.metaData - ) - ) || []; - - const keys = _( - diseaseOutbreakEvents.map( - diseaseOutbreak => - diseaseOutbreak.suspectedDiseaseCode || diseaseOutbreak.hazardType - ) - ) - .compact() - .uniq() - .value(); - - const eventTrackerOverviewsForKeys = eventTrackerOverviews.filter(overview => - keys.includes(overview.key) - ); - - const casesIndicatorIds = eventTrackerOverviewsForKeys.map( - overview => overview.suspectedCasesId - ); - - const deathsIndicatorIds = eventTrackerOverviewsForKeys.map( - overview => overview.deathsId - ); - - return Future.joinObj({ - allCases: this.getAnalyticsByIndicators(casesIndicatorIds), - allDeaths: this.getAnalyticsByIndicators(deathsIndicatorIds), - }).flatMap(({ allCases, allDeaths }) => { - const performanceOverviewMetrics: FutureData[] = - diseaseOutbreakEvents.map(event => { - const baseIndicator = mappedIndicators.find( - indicator => indicator.id === event.id - ); - - const key = event.hazardType || event.suspectedDiseaseCode; - if (!key) - return Future.error( - new Error( - `No hazard type or suspected disease found for event : ${event.id}` + return this.datastore + .getObject(PERFORMANCE_OVERVIEW_DIMENSIONS_DATASTORE_KEY) + .flatMap(nullablePerformanceOverviewDimensions => { + return assertOrError( + nullablePerformanceOverviewDimensions, + PERFORMANCE_OVERVIEW_DIMENSIONS_DATASTORE_KEY + ).flatMap(performanceOverviewDimensions => { + return apiToFuture( + this.api.analytics.getEnrollmentsQuery({ + programId: RTSL_ZEBRA_PROGRAM_ID, + dimension: [ + performanceOverviewDimensions.suspectedDisease, + performanceOverviewDimensions.hazardType, + performanceOverviewDimensions.event, + performanceOverviewDimensions.era1ProgramIndicator, + performanceOverviewDimensions.era2ProgramIndicator, + performanceOverviewDimensions.era3ProgramIndicator, + performanceOverviewDimensions.era4ProgramIndicator, + performanceOverviewDimensions.era5ProgramIndicator, + performanceOverviewDimensions.era6ProgramIndicator, + performanceOverviewDimensions.era7ProgramIndicator, + performanceOverviewDimensions.detect7dProgramIndicator, + performanceOverviewDimensions.notify1dProgramIndicator, + performanceOverviewDimensions.respond7dProgramIndicator, + ], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + }) + ).flatMap(indicatorsProgramFuture => { + return this.getAllEventTrackerOverviewIdsFromDatastore().flatMap( + eventTrackerOverviews => { + const mappedIndicators = + indicatorsProgramFuture?.rows.map((row: string[]) => + this.mapRowToBaseIndicator( + row, + indicatorsProgramFuture.headers, + indicatorsProgramFuture.metaData, + performanceOverviewDimensions ) - ); - const currentEventTrackerOverview = - eventTrackerOverviewsForKeys.find( - overview => overview.key === key - ); - - const currentCases = allCases.find( - caseIdValue => - caseIdValue.id === - currentEventTrackerOverview?.suspectedCasesId + ) || []; + + const keys = _( + diseaseOutbreakEvents.map( + diseaseOutbreak => + diseaseOutbreak.suspectedDiseaseCode || + diseaseOutbreak.hazardType + ) + ) + .compact() + .uniq() + .value(); + + const eventTrackerOverviewsForKeys = eventTrackerOverviews.filter( + overview => keys.includes(overview.key) ); - const currentDeaths = allDeaths.find( - death => death.id === currentEventTrackerOverview?.deathsId + const casesIndicatorIds = eventTrackerOverviewsForKeys.map( + overview => overview.suspectedCasesId ); - const duration = `${moment() - .diff(moment(event.emerged.date), "days") - .toString()}d`; - - if (!baseIndicator) { - const metrics = { - id: event.id, - event: event.name, - manager: event.incidentManagerName, - duration: duration, - nationalIncidentStatus: event.incidentStatus, - cases: currentCases?.value || "", - deaths: currentDeaths?.value || "", - } as PerformanceOverviewMetrics; - return Future.success(metrics); - } else { - const metrics = { - ...baseIndicator, - nationalIncidentStatus: event.incidentStatus, - manager: event.incidentManagerName, - duration: duration, - cases: currentCases?.value || "", - deaths: currentDeaths?.value || "", - } as PerformanceOverviewMetrics; - return Future.success(metrics); - } - }); + const deathsIndicatorIds = eventTrackerOverviewsForKeys.map( + overview => overview.deathsId + ); - return Future.sequential(performanceOverviewMetrics); + return Future.joinObj({ + allCases: this.getAnalyticsByIndicators(casesIndicatorIds), + allDeaths: this.getAnalyticsByIndicators(deathsIndicatorIds), + }).flatMap(({ allCases, allDeaths }) => { + const performanceOverviewMetrics: FutureData[] = + diseaseOutbreakEvents.map(event => { + const baseIndicator = mappedIndicators.find( + indicator => indicator.id === event.id + ); + + const key = + event.hazardType || event.suspectedDiseaseCode; + if (!key) + return Future.error( + new Error( + `No hazard type or suspected disease found for event : ${event.id}` + ) + ); + const currentEventTrackerOverview = + eventTrackerOverviewsForKeys.find( + overview => + overview.key === key && + overview.casesDataSource === + event.casesDataSource + ); + + const currentCases = allCases.find( + caseIdValue => + caseIdValue.id === + currentEventTrackerOverview?.suspectedCasesId + ); + + const currentDeaths = allDeaths.find( + death => + death.id === + currentEventTrackerOverview?.deathsId + ); + + const duration = `${moment() + .diff(moment(event.emerged.date), "days") + .toString()}d`; + + if (!baseIndicator) { + const metrics = { + id: event.id, + event: event.name, + manager: event.incidentManagerName, + duration: duration, + nationalIncidentStatus: event.incidentStatus, + cases: currentCases?.value || "", + deaths: currentDeaths?.value || "", + } as PerformanceOverviewMetrics; + return Future.success(metrics); + } else { + const metrics = { + ...baseIndicator, + nationalIncidentStatus: event.incidentStatus, + manager: event.incidentManagerName, + duration: duration, + cases: currentCases?.value || "", + deaths: currentDeaths?.value || "", + } as PerformanceOverviewMetrics; + return Future.success(metrics); + } + }); + + return Future.sequential(performanceOverviewMetrics); + }); + } + ); }); - } - ); - }); + }); + }); } private getAnalyticsByIndicators(ids: Id[]): FutureData { @@ -518,82 +607,174 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos } getDashboard717Performance(): FutureData { - return apiToFuture( - this.api.analytics.get({ - dimension: [`dx:${PERFORMANCE_METRICS_717_IDS.map(({ id }) => id).join(";")}`], - startDate: DEFAULT_START_DATE, - endDate: DEFAULT_END_DATE, - includeMetadataDetails: true, - }) - ).map(res => { - return this.mapIndicatorsTo717PerformanceMetrics(res.rows, PERFORMANCE_METRICS_717_IDS); - }); + return this.datastore + .getObject(PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY) + .flatMap(nullable717PerformanceProgramIndicators => { + return assertOrError( + nullable717PerformanceProgramIndicators, + PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY + ).flatMap(performance717ProgramIndicators => { + const dashboard717PerformanceIndicator = performance717ProgramIndicators.filter( + indicator => indicator.key === "dashboard" + ); + return apiToFuture( + this.api.analytics.get({ + dimension: [ + `dx:${dashboard717PerformanceIndicator + .map(({ id }) => id) + .join(";")}`, + ], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + includeMetadataDetails: true, + }) + ).map(res => { + return this.mapIndicatorsTo717PerformanceMetrics( + res.rows, + dashboard717PerformanceIndicator + ); + }); + }); + }); } getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData { - return apiToFuture( - this.api.analytics.getEnrollmentsQuery({ - programId: RTSL_ZEBRA_PROGRAM_ID, - dimension: [...EVENT_TRACKER_717_IDS.map(({ id }) => id)], - startDate: DEFAULT_START_DATE, - endDate: DEFAULT_END_DATE, - }) - ).flatMap(response => { - const filteredRow = filterAnalyticsEnrollmentDataByDiseaseOutbreakEvent( - diseaseOutbreakEventId, - response.rows, - response.headers - ); - - if (!filteredRow) - return Future.error(new Error("No data found for event tracker 7-1-7 performance")); - - const mappedIndicatorsToRows: string[][] = EVENT_TRACKER_717_IDS.map(({ id }) => { - return [ - id, - filteredRow[response.headers.findIndex(header => header.name === id)] || "", - ]; - }); + return this.datastore + .getObject(PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY) + .flatMap(nullable717PerformanceProgramIndicators => { + return assertOrError( + nullable717PerformanceProgramIndicators, + PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY + ).flatMap(performance717ProgramIndicators => { + const eventTracker717PerformanceIndicator = + performance717ProgramIndicators.filter( + indicator => indicator.key === "event_tracker" + ); + return apiToFuture( + this.api.analytics.getEnrollmentsQuery({ + programId: RTSL_ZEBRA_PROGRAM_ID, + dimension: [...eventTracker717PerformanceIndicator.map(({ id }) => id)], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + }) + ).flatMap(response => { + const filteredRow = filterAnalyticsEnrollmentDataByDiseaseOutbreakEvent( + diseaseOutbreakEventId, + response.rows, + response.headers + ); - return Future.success( - this.mapIndicatorsTo717PerformanceMetrics( - mappedIndicatorsToRows, - EVENT_TRACKER_717_IDS - ) - ); - }); + if (!filteredRow) + return Future.error( + new Error("No data found for event tracker 7-1-7 performance") + ); + + const mappedIndicatorsToRows: string[][] = + eventTracker717PerformanceIndicator.map(({ id }) => { + return [ + id, + filteredRow[ + response.headers.findIndex(header => header.name === id) + ] || "", + ]; + }); + + return Future.success( + this.mapIndicatorsTo717PerformanceMetrics( + mappedIndicatorsToRows, + eventTracker717PerformanceIndicator + ) + ); + }); + }); + }); } private mapRowToBaseIndicator( row: string[], headers: { name: string; column: string }[], - metaData: AnalyticsResponse["metaData"] + metaData: AnalyticsResponse["metaData"], + performanceOverviewDimensions: PerformanceOverviewDimensions ): Partial { return headers.reduce((acc, header, index) => { - const key = Object.keys(IndicatorsId).find( - key => IndicatorsId[key as keyof typeof IndicatorsId] === header.name - ) as Maybe; + const key = Object.keys(performanceOverviewDimensions).find( + key => + performanceOverviewDimensions[key as keyof PerformanceOverviewDimensions] === + 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; + switch (key) { + case "suspectedDisease": + acc.suspectedDisease = + (( + Object.values(metaData.items).find( + item => (item as any).code === row[index] + ) as any + )?.name as DiseaseNames) || ""; + break; + + case "hazardType": + acc.hazardType = + (( + Object.values(metaData.items).find( + item => (item as any).code === row[index] + ) as any + )?.name as HazardNames) || ""; + break; + + case "nationalIncidentStatus": + acc.nationalIncidentStatus = row[index] as NationalIncidentStatus; + break; + + case "teiId": + acc.id = row[index]; + break; + + case "era1ProgramIndicator": + acc.era1 = row[index]; + break; + + case "era2ProgramIndicator": + acc.era2 = row[index]; + break; + + case "era3ProgramIndicator": + acc.era3 = row[index]; + break; + + case "era4ProgramIndicator": + acc.era4 = row[index]; + break; + + case "era5ProgramIndicator": + acc.era5 = row[index]; + break; + + case "era6ProgramIndicator": + acc.era6 = row[index]; + break; + + case "era7ProgramIndicator": + acc.era7 = row[index]; + break; + + case "detect7dProgramIndicator": + acc.detect7d = row[index]; + break; + + case "notify1dProgramIndicator": + acc.notify1d = row[index]; + break; + + case "respond7dProgramIndicator": + acc.respond7d = row[index]; + break; + + default: + acc[key] = row[index]; + break; } return acc; diff --git a/src/data/repositories/UserD2Repository.ts b/src/data/repositories/UserD2Repository.ts index 9c332064..7e5057ba 100644 --- a/src/data/repositories/UserD2Repository.ts +++ b/src/data/repositories/UserD2Repository.ts @@ -1,5 +1,6 @@ import { Future } from "../../domain/entities/generic/Future"; -import { AppDatastoreConfig, User } from "../../domain/entities/User"; +import { User } from "../../domain/entities/User"; +import { AppDatastoreConfig } from "../../domain/entities/AppDatastoreConfig"; import { UserRepository } from "../../domain/repositories/UserRepository"; import { D2Api, MetadataPick } from "../../types/d2-api"; import { apiToFuture, FutureData } from "../api-futures"; diff --git a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts index d6a6b7e1..83b81a39 100644 --- a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts +++ b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts @@ -4,7 +4,8 @@ import { DataStoreClient } from "../../DataStoreClient"; export enum ProgramIndicatorsDatastoreKey { ActiveVerifiedAlerts = "active-verified-alerts-program-indicators", - CasesAlerts = "cases-alerts-program-indicators", + SuspectedCasesAlertsProgram = "suspected-cases-alerts-program-indicators", + SuspectedCasesCasesProgram = "suspected-cases-cases-program-indicators", } export type ProgramIndicatorsDatastore = { @@ -20,11 +21,9 @@ export function getProgramIndicatorsFromDatastore( programIndicatorsDatastoreKey: ProgramIndicatorsDatastoreKey ): FutureData> { switch (programIndicatorsDatastoreKey) { + case ProgramIndicatorsDatastoreKey.SuspectedCasesAlertsProgram: case ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts: - return dataStoreClient.getObject( - programIndicatorsDatastoreKey - ); - case ProgramIndicatorsDatastoreKey.CasesAlerts: + case ProgramIndicatorsDatastoreKey.SuspectedCasesCasesProgram: return dataStoreClient.getObject( programIndicatorsDatastoreKey ); diff --git a/src/data/repositories/consts/CaseDataConstants.ts b/src/data/repositories/consts/CaseDataConstants.ts new file mode 100644 index 00000000..f688afd2 --- /dev/null +++ b/src/data/repositories/consts/CaseDataConstants.ts @@ -0,0 +1,57 @@ +import { GetValue } from "../../../utils/ts-utils"; +import { + CaseData, + DiseaseOutbreakEvent, +} from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { hazardTypeCodeMap } from "./DiseaseOutbreakConstants"; + +export const RTSL_ZEBRA_CASE_PROGRAM_ID = "A0fHWmkFPzX"; +export const RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID = "aEUOfKt3cNP"; +export const RTSL_ZEB_DET_NATIONAL_EVENT_ID_ID = "ylPUzBomYdb"; + +export const casesDataCodes = { + hazardType: "RTSL_ZEB_DET_HAZARD_TYPE", + suspectedDisease: "RTSL_ZEB_DET_SUSPECTED_DISEASE", + lastUpdatedBy: "RTSL_ZEB_ALERTS_EVT_LAST_UPDATED_BY", + lastUpdatedAt: "RTSL_ZEB_ALERTS_EVT_LAST_UPDATED", + suspectedCases: "RTSL_ZEB_DET_SUS_CASES", + probableCases: "RTSL_ZEB_DET_PROB_CASES", + confirmedCases: "RTSL_ZEB_DET_CONF_CASES", + deaths: "RTSL_ZEB_DET_DEATHS", + diseaseOutbreakId: "RTSL_ZEB_DET_NATIONAL_EVENT_ID", +} as const; + +export type CasesDataCode = GetValue; + +export type CasesDataKeyCode = (typeof casesDataCodes)[keyof typeof casesDataCodes]; + +export function isStringInCasesDataCodes(code: string): code is CasesDataKeyCode { + return (Object.values(casesDataCodes) as string[]).includes(code); +} + +export function getCasesDataValuesFromDiseaseOutbreak( + caseData: CaseData, + diseaseOutbreak: DiseaseOutbreakEvent +): Record { + const nationalEventId = diseaseOutbreak.id; + const hazardTypeCode = diseaseOutbreak.hazardType + ? hazardTypeCodeMap[diseaseOutbreak.hazardType] + : ""; + const suspectedDiseaseCode = diseaseOutbreak.suspectedDiseaseCode ?? ""; + + if (!nationalEventId || (!hazardTypeCode && !suspectedDiseaseCode)) { + throw new Error("Missing required data for cases data"); + } + + return { + RTSL_ZEB_DET_HAZARD_TYPE: hazardTypeCode, + RTSL_ZEB_DET_SUSPECTED_DISEASE: suspectedDiseaseCode, + RTSL_ZEB_ALERTS_EVT_LAST_UPDATED_BY: caseData.updatedBy, + RTSL_ZEB_ALERTS_EVT_LAST_UPDATED: new Date().toISOString(), + RTSL_ZEB_DET_SUS_CASES: caseData.suspectedCases.toString(), + RTSL_ZEB_DET_PROB_CASES: caseData.probableCases.toString(), + RTSL_ZEB_DET_CONF_CASES: caseData.confirmedCases.toString(), + RTSL_ZEB_DET_DEATHS: caseData.deaths.toString(), + RTSL_ZEB_DET_NATIONAL_EVENT_ID: nationalEventId, + }; +} diff --git a/src/data/repositories/consts/DiseaseOutbreakConstants.ts b/src/data/repositories/consts/DiseaseOutbreakConstants.ts index ce57bc1c..60e8f11b 100644 --- a/src/data/repositories/consts/DiseaseOutbreakConstants.ts +++ b/src/data/repositories/consts/DiseaseOutbreakConstants.ts @@ -1,4 +1,5 @@ import { + CasesDataSource, DataSource, DiseaseOutbreakEventBaseAttrs, HazardType, @@ -48,6 +49,11 @@ export const dataSourceMap: Record = { RTSL_ZEB_OS_DATA_SOURCE_EBS: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, }; +export const casesDataSourceMap: Record = { + RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, + RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF, +}; + export const diseaseOutbreakCodes = { name: "RTSL_ZEB_TEA_EVENT_NAME", dataSource: "RTSL_ZEB_TEA_DATA_SOURCE", @@ -79,7 +85,7 @@ export const diseaseOutbreakCodes = { responseNarrative: "RTSL_ZEB_TEA_RESPONSE_NARRATIVE", incidentManager: "RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER", notes: "RTSL_ZEB_TEA_NOTES", - caseDataSource: "RTSL_ZEB_TEA_CASE_DATA_SOURCE", + casesDataSource: "RTSL_ZEB_TEA_CASE_DATA_SOURCE", } as const; export type DiseaseOutbreakCode = GetValue; @@ -165,7 +171,7 @@ export function getValueFromDiseaseOutbreak( RTSL_ZEB_TEA_RESPONSE_NARRATIVE: diseaseOutbreak.earlyResponseActions.responseNarrative, RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER: diseaseOutbreak.incidentManagerName, RTSL_ZEB_TEA_NOTES: diseaseOutbreak.notes ?? "", - RTSL_ZEB_TEA_CASE_DATA_SOURCE: "", + RTSL_ZEB_TEA_CASE_DATA_SOURCE: diseaseOutbreak.casesDataSource, }; } diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index 9dc91473..ae9946e0 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -2,28 +2,27 @@ import { DiseaseNames, HazardNames, IncidentStatus, - PerformanceMetrics717, } 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", - era3 = "RdLmpMM7lM5", - era4 = "xT4TgUZhMkk", - era5 = "UwEdN0kWFqv", - era6 = "xtetmvZ9WoV", - era7 = "GgUJMCklxFu", - detect7d = "cGFwM7qiPzl", - notify1d = "HDa3nE7Elxj", - respond7d = "yxVOW4lj4xP", - province = "ouname", - id = "tei", - nationalIncidentStatus = "incidentStatus", -} +export type PerformanceOverviewDimensions = { + teiId: "tei"; + event: Id; + province: "ouname"; + era1ProgramIndicator: Id; + era2ProgramIndicator: Id; + era3ProgramIndicator: Id; + era4ProgramIndicator: Id; + era5ProgramIndicator: Id; + era6ProgramIndicator: Id; + era7ProgramIndicator: Id; + detect7dProgramIndicator: Id; + notify1dProgramIndicator: Id; + respond7dProgramIndicator: Id; + suspectedDisease: Id; + hazardType: Id; + nationalIncidentStatus: "incidentStatus"; +}; type EventTrackerCountIndicatorBase = { id: Id; @@ -46,23 +45,3 @@ export type EventTrackerCountHazardIndicator = EventTrackerCountIndicatorBase & export type EventTrackerCountIndicator = | EventTrackerCountDiseaseIndicator | EventTrackerCountHazardIndicator; - -export const PERFORMANCE_METRICS_717_IDS: PerformanceMetrics717[] = [ - { id: "MFk8jiMSlfC", name: "detection", type: "primary" }, // % of number of alerts that were detected within 7 days of date of emergence - { id: "jD8CfKvvdXt", name: "detection", type: "secondary" }, // Number of alerts notified to public health authorities within 1 day of detection - - { id: "Y6OkqfhGhZb", name: "notification", type: "primary" }, // - { id: "fKvY7kMydl1", name: "notification", type: "secondary" }, // # events response action started 1 day - - { id: "gEVnF77Uz2u", name: "response", type: "primary" }, // % num of alerts responded d within 7d date not - { id: "ZX0uPp3ik81", name: "response", type: "secondary" }, // # events response action started 1 day - - { id: "bs4E7tV8QRN", name: "allTargets", type: "primary" }, // % num of alerts detected within 7d date emergence - { id: "NHP4GvI0O3J", name: "allTargets", type: "secondary" }, -]; - -export const EVENT_TRACKER_717_IDS: PerformanceMetrics717[] = [ - { id: "JuPtc83RFcy", name: "Days to detection", type: "primary" }, - { id: "fNnWRK0SBhD", name: "Days to notification", type: "primary" }, - { id: "dByeVE0Oqtu", name: "Days to early response", type: "primary" }, -]; diff --git a/src/data/repositories/test/CasesFileTestRepository.ts b/src/data/repositories/test/CasesFileTestRepository.ts new file mode 100644 index 00000000..8f869714 --- /dev/null +++ b/src/data/repositories/test/CasesFileTestRepository.ts @@ -0,0 +1,29 @@ +import { CaseFile } from "../../../domain/entities/CasesFile"; +import { Future } from "../../../domain/entities/generic/Future"; +import { Id } from "../../../domain/entities/Ref"; +import { CasesFileRepository } from "../../../domain/repositories/CasesFileRepository"; +import { FutureData } from "../../api-futures"; + +export class CasesFileTestRepository implements CasesFileRepository { + get(_outbreakKey: Id): FutureData { + return Future.success({ + file: new File([], "test"), + fileId: "test", + }); + } + + getTemplate(): FutureData { + return Future.success({ + file: new File([], "test"), + fileId: "test", + }); + } + + save(_diseaseOutbreakEventId: Id, _outbreakKey: string, _file: CaseFile): FutureData { + return Future.success(undefined); + } + + delete(_outbreakKey: Id): FutureData { + return Future.success(undefined); + } +} diff --git a/src/data/repositories/test/ChartConfigTestRepository.ts b/src/data/repositories/test/ChartConfigTestRepository.ts index 8942a9bd..af0eb0db 100644 --- a/src/data/repositories/test/ChartConfigTestRepository.ts +++ b/src/data/repositories/test/ChartConfigTestRepository.ts @@ -1,3 +1,4 @@ +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../domain/entities/generic/Future"; import { ChartConfigRepository } from "../../../domain/repositories/ChartConfigRepository"; import { FutureData } from "../../api-futures"; @@ -6,10 +7,10 @@ export class ChartConfigTestRepository implements ChartConfigRepository { getRiskAssessmentHistory(_chartKey: string): FutureData { return Future.success("1"); } - getCases(_chartkey: string): FutureData { + getCases(_chartkey: string, _casesDataSource: CasesDataSource): FutureData { return Future.success("1"); } - getDeaths(_chartKey: string): FutureData { + getDeaths(_chartKey: string, _casesDataSource: CasesDataSource): FutureData { return Future.success("1"); } } diff --git a/src/data/repositories/test/ConfigurationsTestRepository.ts b/src/data/repositories/test/ConfigurationsTestRepository.ts index 445562da..4851de13 100644 --- a/src/data/repositories/test/ConfigurationsTestRepository.ts +++ b/src/data/repositories/test/ConfigurationsTestRepository.ts @@ -15,6 +15,7 @@ export class ConfigurationsTestRepository implements ConfigurationsRepository { notificationSources: [], incidentManagers: [], incidentStatus: [], + casesDataSource: [], }, riskAssessmentGradingConfigurations: { geographicalSpread: [], diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index b7dc9d78..406660aa 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -1,4 +1,5 @@ import { + CasesDataSource, DataSource, DiseaseOutbreakEvent, DiseaseOutbreakEventBaseAttrs, @@ -44,6 +45,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + casesDataSource: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, }); } getAll(): FutureData { @@ -78,6 +80,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + casesDataSource: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, }, { id: "2", @@ -109,6 +112,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + casesDataSource: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, }, ]); } diff --git a/src/data/repositories/test/MapConfigTestRepository.ts b/src/data/repositories/test/MapConfigTestRepository.ts index 4874e8dd..89ee4a95 100644 --- a/src/data/repositories/test/MapConfigTestRepository.ts +++ b/src/data/repositories/test/MapConfigTestRepository.ts @@ -1,10 +1,11 @@ +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; 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 { + public get(_mapKey: MapKey, _casesDataSource?: CasesDataSource): FutureData { return Future.success({ currentApp: "ZEBRA", currentPage: "DASHBOARD", diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts index 0ee08d1b..e1af0751 100644 --- a/src/data/repositories/utils/AlertOutbreakMapper.ts +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -1,8 +1,5 @@ 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 { @@ -53,24 +50,3 @@ export function getAlertValueFromMap( ?.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/DiseaseOutbreakMapper.ts b/src/data/repositories/utils/DiseaseOutbreakMapper.ts index de178d10..36b23e0c 100644 --- a/src/data/repositories/utils/DiseaseOutbreakMapper.ts +++ b/src/data/repositories/utils/DiseaseOutbreakMapper.ts @@ -1,4 +1,7 @@ -import { DiseaseOutbreakEventBaseAttrs } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEventBaseAttrs, +} from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { D2TrackerTrackedEntity, Attribute } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { DiseaseOutbreakCode, @@ -12,6 +15,7 @@ import { RTSL_ZEBRA_ORG_UNIT_ID, RTSL_ZEBRA_PROGRAM_ID, RTSL_ZEBRA_TRACKED_ENTITY_TYPE_ID, + casesDataSourceMap, } from "../consts/DiseaseOutbreakConstants"; import _ from "../../../domain/entities/generic/Collection"; import { SelectedPick } from "@eyeseetea/d2-api/api"; @@ -39,6 +43,9 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( const dataSource = dataSourceMap[fromMap("dataSource")]; const incidentStatus = incidentStatusMap[fromMap("incidentStatus")]; + const casesDataSource = + casesDataSourceMap[fromMap("casesDataSource")] ?? + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR; if (!dataSource || !incidentStatus) throw new Error("Data source or incident status not valid"); @@ -95,6 +102,7 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( responseNarrative: fromMap("responseNarrative"), }, notes: fromMap("notes"), + casesDataSource: casesDataSource, }; return diseaseOutbreak; diff --git a/src/data/repositories/utils/MetadataHelper.ts b/src/data/repositories/utils/MetadataHelper.ts index 7ea8ddb0..5248db2d 100644 --- a/src/data/repositories/utils/MetadataHelper.ts +++ b/src/data/repositories/utils/MetadataHelper.ts @@ -1,6 +1,6 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { Id, Ref } from "../../../domain/entities/Ref"; -import { D2Api } from "../../../types/d2-api"; +import { D2Api, MetadataPick } from "../../../types/d2-api"; import { apiToFuture, FutureData } from "../../api-futures"; import { assertOrError } from "./AssertOrError"; import { Attribute } from "@eyeseetea/d2-api/api/trackedEntityInstances"; @@ -73,3 +73,37 @@ export function getProgramStage(api: D2Api, stageId: Id) { }) ); } + +export function getProgramDataElementsMetadata(api: D2Api, programId: Id) { + return apiToFuture( + api.models.programs.get({ + fields: programDataElementsFields, + filter: { + id: { eq: programId }, + }, + }) + ); +} + +const programDataElementsFields = { + id: true, + programStages: { + id: true, + name: true, + programStageDataElements: { + dataElement: { + id: true, + code: true, + valueType: true, + optionSetValue: true, + optionSet: { options: { name: true, code: true } }, + }, + }, + }, +} as const; + +export type D2ProgramStageDataElement = MetadataPick<{ + programStageDataElements: { + fields: typeof programDataElementsFields.programStages.programStageDataElements; + }; +}>["programStageDataElements"][number]; diff --git a/src/domain/entities/AlertsAndCaseForCasesData.ts b/src/domain/entities/AlertsAndCaseForCasesData.ts new file mode 100644 index 00000000..2c7c12e0 --- /dev/null +++ b/src/domain/entities/AlertsAndCaseForCasesData.ts @@ -0,0 +1,50 @@ +import { Maybe } from "../../utils/ts-utils"; +import { DataSource } from "./disease-outbreak-event/DiseaseOutbreakEvent"; +import { Id, Option } from "./Ref"; + +export type AlertsAndCaseForCasesData = { + lastSyncTime: string; + lastUpdated: string; + nationalDiseaseOutbreakEventId: Id; + alerts?: { + alertId: string; + eventDate: Maybe; + orgUnit: Maybe; + suspectedCases: string; + probableCases: string; + confirmedCases: string; + deaths: string; + }[]; + case?: { + fileId: Id; + fileName: string; + fileType: string; + }; +} & { + [key in "disease" | "hazard"]?: string; +}; + +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: + if (!hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + return hazardName; + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: + if (!diseaseName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + return diseaseName; + } +} diff --git a/src/domain/entities/AppConfigurations.ts b/src/domain/entities/AppConfigurations.ts index f181a07b..8a769de2 100644 --- a/src/domain/entities/AppConfigurations.ts +++ b/src/domain/entities/AppConfigurations.ts @@ -6,6 +6,7 @@ import { RiskAssessmentSummaryOptions, } from "./ConfigurableForm"; import { TeamMember } from "./incident-management-team/TeamMember"; +import { OrgUnit } from "./OrgUnit"; import { LowPopulationAtRisk, @@ -52,4 +53,5 @@ export type Configurations = { incidentManagers: TeamMember[]; responseOfficers: TeamMember[]; }; + orgUnits: OrgUnit[]; }; diff --git a/src/domain/entities/AppDatastoreConfig.ts b/src/domain/entities/AppDatastoreConfig.ts new file mode 100644 index 00000000..49ff5441 --- /dev/null +++ b/src/domain/entities/AppDatastoreConfig.ts @@ -0,0 +1,13 @@ +import { Id } from "./Ref"; + +export type AppDatastoreConfig = { + userGroups: { + visualizer: string[]; + capture: string[]; + admin: string[]; + }; + casesFileTemplate: { + fileId: Id; + fileName: string; + }; +}; diff --git a/src/domain/entities/CasesFile.ts b/src/domain/entities/CasesFile.ts new file mode 100644 index 00000000..a15344ea --- /dev/null +++ b/src/domain/entities/CasesFile.ts @@ -0,0 +1,6 @@ +import { Id } from "./Ref"; + +export type CaseFile = { + fileId?: Id; + file: File; +}; diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index fb472a46..a49733f8 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -2,10 +2,7 @@ import { Maybe } from "../../utils/ts-utils"; import { TeamMember } from "./incident-management-team/TeamMember"; import { Id, Option } from "./Ref"; import { Rule } from "./Rule"; -import { - DiseaseOutbreakEvent, - DiseaseOutbreakEventBaseAttrs, -} from "./disease-outbreak-event/DiseaseOutbreakEvent"; +import { DiseaseOutbreakEvent } from "./disease-outbreak-event/DiseaseOutbreakEvent"; import { FormType } from "../../webapp/pages/form-page/FormPage"; import { RiskAssessmentGrading } from "./risk-assessment/RiskAssessmentGrading"; import { RiskAssessmentSummary } from "./risk-assessment/RiskAssessmentSummary"; @@ -14,6 +11,7 @@ import { ActionPlanAttrs } from "./incident-action-plan/ActionPlan"; import { ResponseAction } from "./incident-action-plan/ResponseAction"; import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; import { Role } from "./incident-management-team/Role"; +import { OrgUnit } from "./OrgUnit"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -23,6 +21,7 @@ export type DiseaseOutbreakEventOptions = { notificationSources: Option[]; incidentStatus: Option[]; incidentManagers: TeamMember[]; + casesDataSource: Option[]; }; export type RiskAssessmentGradingOptions = { @@ -73,9 +72,14 @@ type BaseFormData = { type: FormType; }; export type DiseaseOutbreakEventFormData = BaseFormData & { - type: "disease-outbreak-event"; - entity: Maybe; + type: "disease-outbreak-event" | "disease-outbreak-event-case-data"; + entity: Maybe; options: DiseaseOutbreakEventOptions; + orgUnits: OrgUnit[]; + caseDataFileTemplete: Maybe; + uploadedCasesDataFile: Maybe; + uploadedCasesDataFileId: Maybe; + hasInitiallyCasesDataFile: boolean; }; export type RiskAssessmentGradingFormData = BaseFormData & { diff --git a/src/domain/entities/OrgUnit.ts b/src/domain/entities/OrgUnit.ts index 554c0721..0655de7a 100644 --- a/src/domain/entities/OrgUnit.ts +++ b/src/domain/entities/OrgUnit.ts @@ -1,6 +1,6 @@ import { CodedNamedRef } from "./Ref"; -type OrgUnitLevelType = "Province" | "District"; +export type OrgUnitLevelType = "National" | "Province" | "District"; export type OrgUnit = CodedNamedRef & { level: OrgUnitLevelType; diff --git a/src/domain/entities/User.ts b/src/domain/entities/User.ts index 714b5712..09df6d1d 100644 --- a/src/domain/entities/User.ts +++ b/src/domain/entities/User.ts @@ -1,10 +1,11 @@ import { Struct } from "./generic/Struct"; import { NamedRef } from "./Ref"; +export type Username = string; export interface UserAttrs { id: string; name: string; - username: string; + username: Username; userRoles: UserRole[]; userGroups: NamedRef[]; hasCaptureAccess: boolean; @@ -13,13 +14,6 @@ export interface UserAttrs { export interface UserRole extends NamedRef { authorities: string[]; } -export type AppDatastoreConfig = { - userGroups: { - visualizer: string[]; - capture: string[]; - admin: string[]; - }; -}; export class User extends Struct() { belongToUserGroup(userGroupUid: string): boolean { diff --git a/src/domain/entities/ValidationError.ts b/src/domain/entities/ValidationError.ts index 8988c51a..be17c8bf 100644 --- a/src/domain/entities/ValidationError.ts +++ b/src/domain/entities/ValidationError.ts @@ -3,10 +3,18 @@ import { Maybe } from "../../utils/ts-utils"; export type ValidationErrorKey = | "field_is_required" | "field_is_required_na" - | "cannot_create_cyclycal_dependency"; + | "cannot_create_cyclycal_dependency" + | "file_missing" + | "file_empty" + | "file_headers_missing" + | "file_dates_missing" + | "file_dates_not_unique" + | "file_org_units_incorrect" + | "file_data_not_number"; export type ValidationError = { property: string; - value: string | boolean | Date | Maybe | string[] | null; + value: string | boolean | Date | Maybe | string[] | null | Maybe; errors: ValidationErrorKey[]; + errorsInFile?: Partial>; }; diff --git a/src/domain/entities/alert/AlertData.ts b/src/domain/entities/alert/AlertData.ts index dade4a44..f7a3f1d3 100644 --- a/src/domain/entities/alert/AlertData.ts +++ b/src/domain/entities/alert/AlertData.ts @@ -1,6 +1,4 @@ -import { Maybe } from "../../../utils/ts-utils"; import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; -import { Id } from "../Ref"; import { Alert } from "./Alert"; export type AlertData = { @@ -11,20 +9,3 @@ export type AlertData = { 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 d6cbf1c4..9bc0727c 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -2,10 +2,12 @@ import { Struct } from "../generic/Struct"; import { IncidentActionPlan } from "../incident-action-plan/IncidentActionPlan"; import { IncidentManagementTeam } from "../incident-management-team/IncidentManagementTeam"; import { TeamMember } from "../incident-management-team/TeamMember"; -import { Code, NamedRef } from "../Ref"; +import { Code, Id, NamedRef } from "../Ref"; import { RiskAssessment } from "../risk-assessment/RiskAssessment"; import { Maybe } from "../../../utils/ts-utils"; import { ValidationError } from "../ValidationError"; +import _ from "../generic/Collection"; +import { Username } from "../User"; export const hazardTypes = [ "Biological:Human", @@ -31,6 +33,11 @@ export enum DataSource { RTSL_ZEB_OS_DATA_SOURCE_EBS = "RTSL_ZEB_OS_DATA_SOURCE_EBS", } +export enum CasesDataSource { + RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR = "RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR", + RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF = "RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF", +} + type DateWithNarrative = { date: Date; narrative: string; @@ -52,6 +59,16 @@ type EarlyResponseActions = { responseNarrative: string; }; +export type CaseData = { + updatedBy: Username; + orgUnit: Id; + reportDate: string; + suspectedCases: number; + probableCases: number; + confirmedCases: number; + deaths: number; +}; + export type DiseaseOutbreakEventBaseAttrs = NamedRef & { status: "ACTIVE" | "COMPLETED" | "CANCELLED"; created?: Date; @@ -69,17 +86,19 @@ export type DiseaseOutbreakEventBaseAttrs = NamedRef & { earlyResponseActions: EarlyResponseActions; incidentManagerName: string; notes: Maybe; + casesDataSource: CasesDataSource; }; export type DiseaseOutbreakEventAttrs = DiseaseOutbreakEventBaseAttrs & { createdBy: Maybe; mainSyndrome: Maybe; suspectedDisease: Maybe; - notificationSource: NamedRef; + notificationSource: Maybe; incidentManager: Maybe; //TO DO : make mandatory once form rules applied. riskAssessment: Maybe; incidentActionPlan: Maybe; incidentManagementTeam: Maybe; + uploadedCasesData: Maybe; }; /** @@ -92,4 +111,8 @@ export class DiseaseOutbreakEvent extends Struct() { static validate(_data: DiseaseOutbreakEventBaseAttrs): ValidationError[] { return []; } + + addUploadedCasesData(casesData: CaseData[]): DiseaseOutbreakEvent { + return this._update({ uploadedCasesData: casesData }); + } } diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts index 59596a6b..cbad5dfd 100644 --- a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -79,4 +79,5 @@ export type PerformanceMetrics717 = { name: string; type: "primary" | "secondary"; value?: number | "Inc"; + key: "dashboard" | "event_tracker"; }; diff --git a/src/domain/entities/incident-management-team/TeamMember.ts b/src/domain/entities/incident-management-team/TeamMember.ts index b32c0a62..e7377247 100644 --- a/src/domain/entities/incident-management-team/TeamMember.ts +++ b/src/domain/entities/incident-management-team/TeamMember.ts @@ -1,5 +1,6 @@ import { Maybe } from "../../../utils/ts-utils"; import { NamedRef } from "../Ref"; +import { Username } from "../User"; import { Struct } from "../generic/Struct"; type PhoneNumber = string; @@ -12,7 +13,7 @@ export type TeamRole = NamedRef & { }; interface TeamMemberAttrs extends NamedRef { - username: string; + username: Username; phone: Maybe; email: Maybe; status: Maybe; diff --git a/src/domain/repositories/CasesFileRepository.ts b/src/domain/repositories/CasesFileRepository.ts new file mode 100644 index 00000000..a31d3e5c --- /dev/null +++ b/src/domain/repositories/CasesFileRepository.ts @@ -0,0 +1,10 @@ +import { FutureData } from "../../data/api-futures"; +import { CaseFile } from "../entities/CasesFile"; +import { Id } from "../entities/Ref"; + +export interface CasesFileRepository { + get(outbreakKey: string): FutureData; + getTemplate(): FutureData; + save(diseaseOutbreakEventId: Id, outbreakKey: string, file: CaseFile): FutureData; + delete(outbreakKey: string): FutureData; +} diff --git a/src/domain/repositories/ChartConfigRepository.ts b/src/domain/repositories/ChartConfigRepository.ts index 5f61084a..d9261004 100644 --- a/src/domain/repositories/ChartConfigRepository.ts +++ b/src/domain/repositories/ChartConfigRepository.ts @@ -1,7 +1,8 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; export interface ChartConfigRepository { - getCases(chartkey: string): FutureData; - getDeaths(chartKey: string): FutureData; + getCases(chartkey: string, casesDataSource: CasesDataSource): FutureData; + getDeaths(chartKey: string, casesDataSource: CasesDataSource): FutureData; getRiskAssessmentHistory(chartKey: string): FutureData; } diff --git a/src/domain/repositories/DiseaseOutbreakEventRepository.ts b/src/domain/repositories/DiseaseOutbreakEventRepository.ts index 63a5ff33..1e874712 100644 --- a/src/domain/repositories/DiseaseOutbreakEventRepository.ts +++ b/src/domain/repositories/DiseaseOutbreakEventRepository.ts @@ -1,10 +1,13 @@ import { FutureData } from "../../data/api-futures"; -import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../entities/Ref"; export interface DiseaseOutbreakEventRepository { get(id: Id): FutureData; getAll(): FutureData; - save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData; + save(diseaseOutbreak: DiseaseOutbreakEvent, haveChangedCasesData?: boolean): FutureData; complete(id: Id): FutureData; } diff --git a/src/domain/repositories/MapConfigRepository.ts b/src/domain/repositories/MapConfigRepository.ts index 85a4f76f..422f3deb 100644 --- a/src/domain/repositories/MapConfigRepository.ts +++ b/src/domain/repositories/MapConfigRepository.ts @@ -1,6 +1,7 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { MapConfig, MapKey } from "../entities/MapConfig"; export interface MapConfigRepository { - get(mapKey: MapKey): FutureData; + get(mapKey: MapKey, casesDataSource?: CasesDataSource): FutureData; } diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index 9fba312a..6704844a 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -1,5 +1,8 @@ import { FutureData } from "../../data/api-futures"; -import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { TotalCardCounts, PerformanceOverviewMetrics, @@ -20,5 +23,8 @@ export interface PerformanceOverviewRepository { ): FutureData; getDashboard717Performance(): FutureData; getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData; - getEventTrackerOverviewMetrics(type: string): FutureData; + getEventTrackerOverviewMetrics( + type: string, + casesDataSource: CasesDataSource + ): FutureData; } diff --git a/src/domain/usecases/CompleteEventTrackerUseCase.ts b/src/domain/usecases/CompleteEventTrackerUseCase.ts index ec08661b..2f9c82c7 100644 --- a/src/domain/usecases/CompleteEventTrackerUseCase.ts +++ b/src/domain/usecases/CompleteEventTrackerUseCase.ts @@ -1,15 +1,48 @@ import { FutureData } from "../../data/api-futures"; -import { Id } from "../entities/Ref"; +import { getOutbreakKey } from "../entities/AlertsAndCaseForCasesData"; +import { Configurations } from "../entities/AppConfigurations"; +import { + CasesDataSource, + DiseaseOutbreakEvent, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../entities/generic/Future"; +import { CasesFileRepository } from "../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; export class CompleteEventTrackerUseCase { constructor( private options: { diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + casesFileRepository: CasesFileRepository; } ) {} - public execute(id: Id): FutureData { - return this.options.diseaseOutbreakEventRepository.complete(id); + public execute( + diseaseOutbreakEvent: DiseaseOutbreakEvent, + configurations: Configurations + ): FutureData { + return this.options.diseaseOutbreakEventRepository + .complete(diseaseOutbreakEvent.id) + .flatMap(() => { + if ( + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ) { + const outbreakKey = getOutbreakKey({ + dataSource: diseaseOutbreakEvent.dataSource, + outbreakValue: + diseaseOutbreakEvent.suspectedDiseaseCode || + diseaseOutbreakEvent.hazardType, + hazardTypes: + configurations.selectableOptions.eventTrackerConfigurations.hazardTypes, + suspectedDiseases: + configurations.selectableOptions.eventTrackerConfigurations + .suspectedDiseases, + }); + return this.options.casesFileRepository.delete(outbreakKey); + } else { + return Future.success(undefined); + } + }); } } diff --git a/src/domain/usecases/GetAllOrgUnitsUseCase.ts b/src/domain/usecases/GetAllOrgUnitsUseCase.ts deleted file mode 100644 index fcb25711..00000000 --- a/src/domain/usecases/GetAllOrgUnitsUseCase.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FutureData } from "../../data/api-futures"; -import { OrgUnit } from "../entities/OrgUnit"; -import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; - -export class GetAllOrgUnitsUseCase { - constructor(private orgUnitRepository: OrgUnitRepository) {} - - public execute(): FutureData { - return this.orgUnitRepository.getAll(); - } -} diff --git a/src/domain/usecases/GetChartConfigByTypeUseCase.ts b/src/domain/usecases/GetChartConfigByTypeUseCase.ts index 0527ccbd..eeb100a0 100644 --- a/src/domain/usecases/GetChartConfigByTypeUseCase.ts +++ b/src/domain/usecases/GetChartConfigByTypeUseCase.ts @@ -1,19 +1,27 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../entities/generic/Future"; import { ChartConfigRepository } from "../repositories/ChartConfigRepository"; export type ChartType = "deaths" | "cases" | "risk-assessment-history"; export class GetChartConfigByTypeUseCase { constructor(private chartConfigRepository: ChartConfigRepository) {} - public execute(chartType: ChartType, chartKey: string): FutureData { - if (chartType === "deaths") { - return this.chartConfigRepository.getDeaths(chartKey); - } else if (chartType === "cases") { - return this.chartConfigRepository.getCases(chartKey); + public execute( + chartType: ChartType, + chartKey: string, + casesDataSource?: CasesDataSource + ): FutureData { + if (chartType === "deaths" && casesDataSource) { + return this.chartConfigRepository.getDeaths(chartKey, casesDataSource); + } else if (chartType === "cases" && casesDataSource) { + return this.chartConfigRepository.getCases(chartKey, casesDataSource); } else if (chartType === "risk-assessment-history") { return this.chartConfigRepository.getRiskAssessmentHistory(chartKey); } else { - throw new Error(`Invalid chart type: ${chartType}`); + return Future.error( + new Error(`Invalid chart type: ${chartType} or cases data source is missing`) + ); } } } diff --git a/src/domain/usecases/GetConfigurableFormUseCase.ts b/src/domain/usecases/GetConfigurableFormUseCase.ts index baf9adf3..3de28bd3 100644 --- a/src/domain/usecases/GetConfigurableFormUseCase.ts +++ b/src/domain/usecases/GetConfigurableFormUseCase.ts @@ -3,9 +3,13 @@ import { Maybe } from "../../utils/ts-utils"; import { FormType } from "../../webapp/pages/form-page/FormPage"; import { Configurations } from "../entities/AppConfigurations"; import { ConfigurableForm } from "../entities/ConfigurableForm"; -import { DiseaseOutbreakEvent } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEvent, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; +import { CasesFileRepository } from "../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; import { IncidentActionRepository } from "../repositories/IncidentActionRepository"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; @@ -30,6 +34,7 @@ export class GetConfigurableFormUseCase { teamMemberRepository: TeamMemberRepository; incidentActionRepository: IncidentActionRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; + casesFileRepository: CasesFileRepository; } ) {} @@ -43,8 +48,25 @@ export class GetConfigurableFormUseCase { const { formType, eventTrackerDetails, configurations, id, responseActionId } = options; switch (formType) { - case "disease-outbreak-event": { - return getDiseaseOutbreakConfigurableForm(this.options, configurations, id); + case "disease-outbreak-event": + case "disease-outbreak-event-case-data": { + if ( + formType === "disease-outbreak-event-case-data" && + (id !== eventTrackerDetails?.id || + eventTrackerDetails?.casesDataSource !== + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF) + ) { + return Future.error( + new Error("Cases data source in disease outbreak is not user defined.") + ); + } + + return getDiseaseOutbreakConfigurableForm( + this.options, + configurations, + formType, + id + ); } case "risk-assessment-grading": if (!eventTrackerDetails) diff --git a/src/domain/usecases/GetConfigurationsUseCase.ts b/src/domain/usecases/GetConfigurationsUseCase.ts index 11791bb6..b2b173ee 100644 --- a/src/domain/usecases/GetConfigurationsUseCase.ts +++ b/src/domain/usecases/GetConfigurationsUseCase.ts @@ -3,6 +3,7 @@ import { Configurations, SelectableOptions } from "../entities/AppConfigurations import { Future } from "../entities/generic/Future"; import { TeamMember } from "../entities/incident-management-team/TeamMember"; import { ConfigurationsRepository } from "../repositories/ConfigurationsRepository"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { UserGroupRepository } from "../repositories/UserGroupRepository"; @@ -10,6 +11,7 @@ export class GetConfigurationsUseCase { constructor( private configurationsRepository: ConfigurationsRepository, private teamMemberRepository: TeamMemberRepository, + private orgUnitRepository: OrgUnitRepository, private userGroupRepository: UserGroupRepository ) {} @@ -20,6 +22,7 @@ export class GetConfigurationsUseCase { managers: this.teamMemberRepository.getIncidentManagers(), riskAssessors: this.teamMemberRepository.getRiskAssessors(), selectableOptionsResponse: this.configurationsRepository.getSelectableOptions(), + orgUnits: this.orgUnitRepository.getAll(), incidentManagerUserGroup: this.userGroupRepository.getIncidentManagerUserGroupByCode(), }).flatMap( ({ @@ -28,6 +31,7 @@ export class GetConfigurationsUseCase { managers, riskAssessors, selectableOptionsResponse, + orgUnits, incidentManagerUserGroup, }) => { const selectableOptions: SelectableOptions = @@ -47,6 +51,7 @@ export class GetConfigurationsUseCase { incidentManagers: managers, responseOfficers: incidentResponseOfficers, }, + orgUnits, }; return Future.success(configurations); } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 93fa90e1..a7bca345 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -81,6 +81,7 @@ export class GetDiseaseOutbreakByIdUseCase { riskAssessment: riskAssessment, incidentActionPlan: undefined, //IAP is fetched on menu click. It is not needed here. incidentManagementTeam: undefined, //IMT is fetched on menu click. It is not needed here. + uploadedCasesData: undefined, // Uploaded case data is not needed }); return Future.success(diseaseOutbreakEvent); }); diff --git a/src/domain/usecases/GetMapConfigUseCase.ts b/src/domain/usecases/GetMapConfigUseCase.ts index 4665e5a8..b5983eda 100644 --- a/src/domain/usecases/GetMapConfigUseCase.ts +++ b/src/domain/usecases/GetMapConfigUseCase.ts @@ -1,11 +1,12 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; 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); + public execute(mapKey: MapKey, casesDataSource?: CasesDataSource): FutureData { + return this.mapConfigRepository.get(mapKey, casesDataSource); } } diff --git a/src/domain/usecases/GetOverviewCardsUseCase.ts b/src/domain/usecases/GetOverviewCardsUseCase.ts index 2fdfa6ce..f93e5767 100644 --- a/src/domain/usecases/GetOverviewCardsUseCase.ts +++ b/src/domain/usecases/GetOverviewCardsUseCase.ts @@ -1,11 +1,15 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { OverviewCard } from "../entities/PerformanceOverview"; import { PerformanceOverviewRepository } from "../repositories/PerformanceOverviewRepository"; export class GetOverviewCardsUseCase { constructor(private performanceOverviewRepository: PerformanceOverviewRepository) {} - public execute(type: string): FutureData { - return this.performanceOverviewRepository.getEventTrackerOverviewMetrics(type); + public execute(type: string, casesDataSource: CasesDataSource): FutureData { + return this.performanceOverviewRepository.getEventTrackerOverviewMetrics( + type, + casesDataSource + ); } } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index b1f581a0..42987a2d 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -1,7 +1,10 @@ import { FutureData } from "../../data/api-futures"; import { INCIDENT_MANAGER_ROLE } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { ConfigurableForm } from "../entities/ConfigurableForm"; -import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; @@ -13,6 +16,7 @@ import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbrea import { RoleRepository } from "../repositories/RoleRepository"; import { Configurations } from "../entities/AppConfigurations"; import moment from "moment"; +import { CasesFileRepository } from "../repositories/CasesFileRepository"; export class SaveEntityUseCase { constructor( @@ -23,28 +27,46 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; roleRepository: RoleRepository; + casesFileRepository: CasesFileRepository; } ) {} public execute( formData: ConfigurableForm, configurations: Configurations, + editMode: boolean, formOptionsToDelete?: Id[] ): FutureData { if (!formData || !formData.entity) return Future.error(new Error("No form data found")); switch (formData.type) { case "disease-outbreak-event": + case "disease-outbreak-event-case-data": { + const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...formData.entity, + + // NOTICE: Not needed for saving + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); + return saveDiseaseOutbreak( + this.options, + diseaseOutbreakEvent, + configurations, + editMode, { - diseaseOutbreakEventRepository: this.options.diseaseOutbreakEventRepository, - incidentManagementTeamRepository: - this.options.incidentManagementTeamRepository, - teamMemberRepository: this.options.teamMemberRepository, - roleRepository: this.options.roleRepository, - }, - formData.entity, - configurations + uploadedCasesDataFile: formData.uploadedCasesDataFile, + uploadedCasesDataFileId: formData.uploadedCasesDataFileId, + hasInitiallyCasesDataFile: formData.hasInitiallyCasesDataFile, + } ); + } case "risk-assessment-grading": case "risk-assessment-summary": case "risk-assessment-questionnaire": @@ -84,17 +106,26 @@ export class SaveEntityUseCase { incidentManagerName: updatedIncidentManager, }; + const diseaseOutbreakEvent: DiseaseOutbreakEvent = + new DiseaseOutbreakEvent({ + ...updatedDiseaseOutbreakEvent, + + // NOTICE: Not needed for saving + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + uploadedCasesData: undefined, + }); return saveDiseaseOutbreak( - { - diseaseOutbreakEventRepository: - this.options.diseaseOutbreakEventRepository, - incidentManagementTeamRepository: - this.options.incidentManagementTeamRepository, - teamMemberRepository: this.options.teamMemberRepository, - roleRepository: this.options.roleRepository, - }, - updatedDiseaseOutbreakEvent, - configurations + this.options, + diseaseOutbreakEvent, + configurations, + editMode ); } else { return Future.success(undefined); diff --git a/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts b/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts index 6f384401..4a7aae12 100644 --- a/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts +++ b/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts @@ -1,39 +1,102 @@ import { FutureData } from "../../../../data/api-futures"; import { DiseaseOutbreakEventFormData, FormLables } from "../../../entities/ConfigurableForm"; -import { DataSource } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DataSource, + DiseaseOutbreakEvent, +} from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; import { Id } from "../../../entities/Ref"; import { Rule } from "../../../entities/Rule"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; import { Configurations } from "../../../entities/AppConfigurations"; +import { CasesFileRepository } from "../../../repositories/CasesFileRepository"; +import { getOutbreakKey } from "../../../entities/AlertsAndCaseForCasesData"; export function getDiseaseOutbreakConfigurableForm( options: { diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + casesFileRepository: CasesFileRepository; }, configurations: Configurations, + formType: "disease-outbreak-event" | "disease-outbreak-event-case-data", id?: Id ): FutureData { const { rules, labels } = getEventTrackerLabelsRules(); const diseaseOutbreakForm: DiseaseOutbreakEventFormData = { - type: "disease-outbreak-event", + type: formType, entity: undefined, + uploadedCasesDataFile: undefined, + uploadedCasesDataFileId: undefined, + hasInitiallyCasesDataFile: false, + caseDataFileTemplete: undefined, rules: rules, labels: labels, options: configurations.selectableOptions.eventTrackerConfigurations, + orgUnits: configurations.orgUnits, }; if (id) { return options.diseaseOutbreakEventRepository.get(id).flatMap(diseaseOutbreakEventBase => { + const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + + // NOTICE: Not needed in form but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + uploadedCasesData: undefined, + }); + + const outbreakKey = getOutbreakKey({ + dataSource: diseaseOutbreakEvent.dataSource, + outbreakValue: + diseaseOutbreakEvent.suspectedDiseaseCode || diseaseOutbreakEvent.hazardType, + hazardTypes: + configurations.selectableOptions.eventTrackerConfigurations.hazardTypes, + suspectedDiseases: + configurations.selectableOptions.eventTrackerConfigurations.suspectedDiseases, + }); + + const hasCasesDataFile = + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + const populatedDiseaseOutbreakForm: DiseaseOutbreakEventFormData = { ...diseaseOutbreakForm, - entity: diseaseOutbreakEventBase, + entity: diseaseOutbreakEvent, }; - return Future.success(populatedDiseaseOutbreakForm); + + if (hasCasesDataFile) { + return options.casesFileRepository.getTemplate().flatMap(casesFileTemplate => { + return options.casesFileRepository.get(outbreakKey).flatMap(casesDataFile => { + const populatedDiseaseOutbreakFormWithFile: DiseaseOutbreakEventFormData = { + ...populatedDiseaseOutbreakForm, + caseDataFileTemplete: casesFileTemplate.file, + uploadedCasesDataFile: casesDataFile.file, + uploadedCasesDataFileId: casesDataFile.fileId, + hasInitiallyCasesDataFile: true, + }; + return Future.success(populatedDiseaseOutbreakFormWithFile); + }); + }); + } else { + return Future.success(populatedDiseaseOutbreakForm); + } }); } else { - return Future.success(diseaseOutbreakForm); + return options.casesFileRepository.getTemplate().flatMap(casesFileTemplate => { + return Future.success({ + ...diseaseOutbreakForm, + caseDataFileTemplete: casesFileTemplate.file, + }); + }); } } @@ -44,6 +107,8 @@ function getEventTrackerLabelsRules(): { rules: Rule[]; labels: FormLables } { errors: { field_is_required: "This field is required", field_is_required_na: "This field is required when not applicable", + file_missing: "File is missing", + file_empty: "File is empty", }, }, // TODO: Get rules from Datastore used in applyRulesInFormState @@ -60,6 +125,12 @@ function getEventTrackerLabelsRules(): { rules: Rule[]; labels: FormLables } { fieldValue: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, sectionIds: ["mainSyndrome_section", "suspectedDisease_section"], }, + { + type: "toggleSectionsVisibilityByFieldValue", + fieldId: "casesDataSource", + fieldValue: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF, + sectionIds: ["casesDataFile_section"], + }, ], }; } diff --git a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts index 4bb144ec..9ea485df 100644 --- a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts +++ b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts @@ -1,15 +1,22 @@ import { FutureData } from "../../../../data/api-futures"; import { INCIDENT_MANAGER_ROLE } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { getOutbreakKey } from "../../../entities/AlertsAndCaseForCasesData"; import { Configurations } from "../../../entities/AppConfigurations"; -import { DiseaseOutbreakEventBaseAttrs } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; import { Role } from "../../../entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../../../entities/incident-management-team/TeamMember"; import { Id } from "../../../entities/Ref"; +import { CasesFileRepository } from "../../../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; import { RoleRepository } from "../../../repositories/RoleRepository"; import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; +import { Maybe } from "../../../../utils/ts-utils"; export function saveDiseaseOutbreak( repositories: { @@ -17,19 +24,75 @@ export function saveDiseaseOutbreak( incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; roleRepository: RoleRepository; + casesFileRepository: CasesFileRepository; }, - diseaseOutbreakEvent: DiseaseOutbreakEventBaseAttrs, - configurations: Configurations + diseaseOutbreakEvent: DiseaseOutbreakEvent, + configurations: Configurations, + editMode: boolean, + casesDataOptions?: { + uploadedCasesDataFile: Maybe; + uploadedCasesDataFileId: Maybe; + hasInitiallyCasesDataFile: boolean; + } ): FutureData { + const { uploadedCasesDataFile, uploadedCasesDataFileId, hasInitiallyCasesDataFile } = + casesDataOptions || {}; + + const hasNewCasesData = + (!editMode || !hasInitiallyCasesDataFile) && + !!uploadedCasesDataFile && + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + const haveChangedCasesData = + editMode && + hasInitiallyCasesDataFile && + !uploadedCasesDataFileId && + !!uploadedCasesDataFile && + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + return repositories.diseaseOutbreakEventRepository - .save(diseaseOutbreakEvent) + .save(diseaseOutbreakEvent, haveChangedCasesData) .flatMap((diseaseOutbreakId: Id) => { const diseaseOutbreakEventWithId = { ...diseaseOutbreakEvent, id: diseaseOutbreakId }; return saveIncidentManagerTeamMemberRole( repositories, diseaseOutbreakEventWithId, configurations - ); + ).flatMap(() => { + if (hasNewCasesData || haveChangedCasesData) { + const outbreakKey = getOutbreakKey({ + dataSource: diseaseOutbreakEventWithId.dataSource, + outbreakValue: + diseaseOutbreakEventWithId.suspectedDiseaseCode || + diseaseOutbreakEventWithId.hazardType, + hazardTypes: + configurations.selectableOptions.eventTrackerConfigurations.hazardTypes, + suspectedDiseases: + configurations.selectableOptions.eventTrackerConfigurations + .suspectedDiseases, + }); + + if (haveChangedCasesData) { + // NOTICE: If the cases data file has changed, we need to replace the old one with the new one + return repositories.casesFileRepository.delete(outbreakKey).flatMap(() => { + return repositories.casesFileRepository + .save(diseaseOutbreakEvent.id, outbreakKey, { + file: uploadedCasesDataFile, + }) + .flatMap(() => Future.success(diseaseOutbreakId)); + }); + } else { + return repositories.casesFileRepository + .save(diseaseOutbreakEvent.id, outbreakKey, { + file: uploadedCasesDataFile, + }) + .flatMap(() => Future.success(diseaseOutbreakId)); + } + } else { + return Future.success(diseaseOutbreakId); + } + }); }); } diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index 53f4597a..54b92691 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -16,10 +16,7 @@ import { NotificationD2Repository } from "../data/repositories/NotificationD2Rep import { Future } from "../domain/entities/generic/Future"; import { getTEAttributeById, getUserGroupByCode } from "../data/repositories/utils/MetadataHelper"; import { NotifyWatchStaffUseCase } from "../domain/usecases/NotifyWatchStaffUseCase"; -import { - getOutbreakKey, - mapTrackedEntityAttributesToAlertOptions, -} from "../data/repositories/utils/AlertOutbreakMapper"; +import { mapTrackedEntityAttributesToAlertOptions } from "../data/repositories/utils/AlertOutbreakMapper"; import { AlertSyncDataStoreRepository } from "../data/repositories/AlertSyncDataStoreRepository"; import { getNotificationOptionsFromTrackedEntity } from "../data/repositories/utils/NotificationMapper"; import { AlertData } from "../domain/entities/alert/AlertData"; @@ -33,6 +30,7 @@ import { Alert } from "../domain/entities/alert/Alert"; import { AlertOptions } from "../domain/repositories/AlertRepository"; import { Option } from "../domain/entities/Ref"; import { ConfigurationsD2Repository } from "../data/repositories/ConfigurationsD2Repository"; +import { getOutbreakKey } from "../domain/entities/AlertsAndCaseForCasesData"; //TO DO : Fetch from metadata on app load const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index 1ad68ef1..c67c8c11 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -17,7 +17,6 @@ export function getTestContext() { currentUser: createAdminUser(), compositionRoot: getTestCompositionRoot(), api: {} as D2Api, - orgUnits: [], isDev: true, configurations: { incidentManagerUserGroup: { id: "incidentManagerUserGroup" }, @@ -30,6 +29,7 @@ export function getTestContext() { notificationSources: [], incidentStatus: [], incidentManagers: [], + casesDataSource: [], }, riskAssessmentGradingConfigurations: { populationAtRisk: [], @@ -68,6 +68,7 @@ export function getTestContext() { incidentManagers: [], responseOfficers: [], }, + orgUnits: [], }, }; diff --git a/src/webapp/components/chart/Chart.tsx b/src/webapp/components/chart/Chart.tsx index eb7b41bb..a084220e 100644 --- a/src/webapp/components/chart/Chart.tsx +++ b/src/webapp/components/chart/Chart.tsx @@ -6,19 +6,21 @@ import { useChart } from "./useChart"; import { Maybe } from "../../../utils/ts-utils"; import LoaderContainer from "../loader/LoaderContainer"; import { ChartType } from "../../../domain/usecases/GetChartConfigByTypeUseCase"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type ChartProps = { title: string; chartType: ChartType; chartKey: Maybe; + casesDataSource?: CasesDataSource; hasSeparator?: boolean; lastUpdated?: string; }; export const Chart: React.FC = React.memo(props => { const { api } = useAppContext(); - const { title, hasSeparator, lastUpdated, chartType, chartKey } = props; + const { title, hasSeparator, lastUpdated, chartType, chartKey, casesDataSource } = props; - const { id } = useChart(chartType, chartKey); + const { id } = useChart(chartType, chartKey, casesDataSource); const chartUrl = `${api.baseUrl}/dhis-web-data-visualizer/#/${id}`; diff --git a/src/webapp/components/chart/useChart.ts b/src/webapp/components/chart/useChart.ts index f803749d..fb520f55 100644 --- a/src/webapp/components/chart/useChart.ts +++ b/src/webapp/components/chart/useChart.ts @@ -2,8 +2,13 @@ import { useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import { Maybe } from "../../../utils/ts-utils"; import { ChartType } from "../../../domain/usecases/GetChartConfigByTypeUseCase"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -export function useChart(chartType: ChartType, chartKey: Maybe) { +export function useChart( + chartType: ChartType, + chartKey: Maybe, + casesDataSource?: CasesDataSource +) { const { compositionRoot } = useAppContext(); const [id, setId] = useState(); @@ -11,7 +16,7 @@ export function useChart(chartType: ChartType, chartKey: Maybe) { if (!chartKey) { return; } - compositionRoot.charts.getCases.execute(chartType, chartKey).run( + compositionRoot.charts.getCases.execute(chartType, chartKey, casesDataSource).run( chartId => { setId(chartId); }, @@ -19,7 +24,7 @@ export function useChart(chartType: ChartType, chartKey: Maybe) { console.error(error); } ); - }, [chartKey, chartType, compositionRoot.charts.getCases]); + }, [casesDataSource, chartKey, chartType, compositionRoot.charts.getCases]); return { id }; } diff --git a/src/webapp/components/form/FieldWidget.tsx b/src/webapp/components/form/FieldWidget.tsx index bfdcdfcf..a1e56779 100644 --- a/src/webapp/components/form/FieldWidget.tsx +++ b/src/webapp/components/form/FieldWidget.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from "react"; -import i18n from "../../../utils/i18n"; import { TextInput } from "../text-input/TextInput"; import { UserSelector } from "../user-selector/UserSelector"; import { MultipleSelector } from "../selector/MultipleSelector"; @@ -9,7 +8,8 @@ import { RadioButtonsGroup } from "../radio-buttons-group/RadioButtonsGroup"; import { TextArea } from "../text-input/TextArea"; import { DatePicker } from "../date-picker/DatePicker"; import { Checkbox } from "../checkbox/Checkbox"; -import { FormFieldState, updateFieldState } from "./FormFieldsState"; +import { FormFieldState, updateFieldState, SheetData } from "./FormFieldsState"; +import { ImportFile } from "../import-file/ImportFile"; export type FieldWidgetProps = { onChange: (updatedField: FormFieldState) => void; @@ -22,8 +22,8 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E const { field, onChange, disabled = false, errorLabels } = props; const notifyChange = useCallback( - (newValue: FormFieldState["value"]) => { - onChange(updateFieldState(field, newValue)); + (newValue: FormFieldState["value"], sheetsData?: SheetData[]) => { + onChange(updateFieldState(field, newValue, sheetsData)); }, [field, onChange] ); @@ -35,11 +35,7 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E helperText: field.helperText, errorText: field.errors ? field.errors - .map(error => - errorLabels && errorLabels[error] - ? errorLabels[error] - : i18n.t("There is an error in this field") - ) + .map(error => (errorLabels && errorLabels[error] ? errorLabels[error] : error)) .join("\n") : "", error: field.errors && field.errors.length > 0, @@ -100,5 +96,18 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E /> ); } + + case "file": { + return ( + + ); + } } }); diff --git a/src/webapp/components/form/Form.tsx b/src/webapp/components/form/Form.tsx index 8493665f..3c63e385 100644 --- a/src/webapp/components/form/Form.tsx +++ b/src/webapp/components/form/Form.tsx @@ -1,4 +1,5 @@ import React from "react"; +import styled from "styled-components"; import { useLocalForm } from "./useLocalForm"; import { FormState } from "./FormState"; @@ -6,10 +7,23 @@ import { FormLayout } from "./FormLayout"; import { FormSection } from "./FormSection"; import { Layout } from "../layout/Layout"; import { FormFieldState } from "./FormFieldsState"; +import { SimpleModal } from "../simple-modal/SimpleModal"; +import { Button } from "../button/Button"; + +export type ModalData = { + title: string; + content: string; + cancelLabel: string; + confirmLabel: string; + onConfirm: () => void; +}; export type FormProps = { formState: FormState; errorLabels?: Record; + openModal: boolean; + modalData?: ModalData; + setOpenModal: (show: boolean) => void; onFormChange: (updatedField: FormFieldState) => void; onSave: () => void; onCancel?: () => void; @@ -18,8 +32,18 @@ export type FormProps = { }; export const Form: React.FC = React.memo(props => { - const { formState, onFormChange, onSave, onCancel, errorLabels, handleAddNew, handleRemove } = - props; + const { + formState, + onFormChange, + onSave, + onCancel, + errorLabels, + handleAddNew, + handleRemove, + openModal, + modalData, + setOpenModal, + } = props; const { formLocalState, handleUpdateFormField } = useLocalForm(formState, onFormChange); return ( @@ -57,6 +81,25 @@ export const Form: React.FC = React.memo(props => { ); })} + {modalData ? ( + setOpenModal(false)} + title={modalData.title} + closeLabel={modalData.cancelLabel} + footerButtons={ + + } + > + {openModal && {modalData.content}} + + ) : null} ); }); + +const Text = styled.div` + font-weight: 400; + font-size: 0.875rem; + color: ${props => props.theme.palette.common.grey900}; +`; diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 2b11d789..6e65fe89 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -5,8 +5,17 @@ import { Option } from "../utils/option"; import { ValidationError, ValidationErrorKey } from "../../../domain/entities/ValidationError"; import { FormSectionState } from "./FormSectionsState"; import { Rule } from "../../../domain/entities/Rule"; - -export type FieldType = "text" | "boolean" | "select" | "radio" | "date" | "user" | "addNew"; +import { Id } from "../../../domain/entities/Ref"; + +export type FieldType = + | "text" + | "boolean" + | "select" + | "radio" + | "date" + | "user" + | "addNew" + | "file"; type FormFieldStateBase = { id: string; @@ -57,6 +66,20 @@ export type FormAvatarFieldState = FormFieldStateBase> & { options: User[]; }; +export type Row = Record; +export type SheetData = { + name: string; + headers: string[]; + rows: Row[]; +}; +export type FormFileFieldState = FormFieldStateBase> & { + type: "file"; + data: Maybe; + fileId: Maybe; + fileTemplate: Maybe; + fileNameLabel?: string; +}; + export type AddNewFieldState = FormFieldStateBase & { type: "addNew"; }; @@ -67,7 +90,8 @@ export type FormFieldState = | FormMultipleOptionsFieldState | FormBooleanFieldState | FormDateFieldState - | FormAvatarFieldState; + | FormAvatarFieldState + | FormFileFieldState; // HELPERS: @@ -105,6 +129,20 @@ export function getMultipleOptionsFieldValue(id: string, allFields: FormFieldSta return getFieldValueById(id, allFields) || []; } +export function getFileFieldValue(id: string, allFields: FormFieldState[]): Maybe { + return getFieldValueById(id, allFields); +} + +export function getFieldFileDataById(id: string, allFields: FormFieldState[]): Maybe { + const field = allFields.find(field => field.id === id && field.type === "file"); + return field?.type === "file" ? field.data : undefined; +} + +export function getFieldFileIdById(id: string, allFields: FormFieldState[]): Maybe { + const field = allFields.find(field => field.id === id && field.type === "file"); + return field?.type === "file" ? field.fileId : undefined; +} + export function getFieldValueById( id: string, fields: FormFieldState[] @@ -137,6 +175,8 @@ export function getFieldWithEmptyValue(field: FormFieldState): FormFieldState { return { ...field, value: null }; case "user": return { ...field, value: undefined }; + case "file": + return { ...field, value: undefined, data: undefined, fileId: undefined }; } } @@ -152,18 +192,21 @@ export function updateFields( fieldValidationErrors?: ValidationError[] ): FormFieldState[] { return formFields.map(field => { - const errors = - fieldValidationErrors?.find(error => error.property === field.id)?.errors || []; + const validationError = fieldValidationErrors?.find(error => error.property === field.id); + const errors = validationError?.errors || []; + const errorsInFile = validationError?.errorsInFile + ? Object.values(validationError.errorsInFile).filter(error => error !== undefined) + : []; if (field.id === updatedField.id) { return { ...updatedField, - errors: errors, + errors: [...errors, ...errorsInFile], }; } else { return updatedField.updateAllStateWithValidationErrors ? { ...field, - errors: errors, + errors: [...errors, ...errorsInFile], } : field; } @@ -172,9 +215,19 @@ export function updateFields( export function updateFieldState( fieldToUpdate: F, - newValue: F["value"] + newValue: F["value"], + sheetsData?: SheetData[] ): F { - return { ...fieldToUpdate, value: newValue }; + if (fieldToUpdate.type === "file") { + return { + ...fieldToUpdate, + value: newValue, + data: sheetsData, + fileId: undefined, // If a new file is uploaded, the fileId should be undefined + }; + } else { + return { ...fieldToUpdate, value: newValue }; + } } // VALIDATIONS: @@ -210,7 +263,11 @@ export function validateField( // RULES: export function hideFieldsAndSetToEmpty(fields: FormFieldState[]): FormFieldState[] { - return fields.map(field => ({ ...getFieldWithEmptyValue(field), isVisible: false })); + return fields.map(field => ({ + ...getFieldWithEmptyValue(field), + isVisible: false, + errors: [], + })); } export function applyRulesInUpdatedField( diff --git a/src/webapp/components/form/__tests__/Form.spec.tsx b/src/webapp/components/form/__tests__/Form.spec.tsx index 95ae764a..37255e20 100644 --- a/src/webapp/components/form/__tests__/Form.spec.tsx +++ b/src/webapp/components/form/__tests__/Form.spec.tsx @@ -233,7 +233,7 @@ function givenFormProps(): FormProps { label: "Field text visible not required", isVisible: true, helperText: "text field helper text not required", - errors: ["this_is_an_error"], + errors: ["There is an error in this field"], type: "text", value: "text value not required", multiline: false, @@ -490,5 +490,7 @@ function givenFormProps(): FormProps { onFormChange: (_updatedField: FormFieldState) => {}, onSave: () => {}, onCancel: () => {}, + openModal: false, + setOpenModal: () => {}, }; } diff --git a/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx b/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx index d949caa0..f9328a96 100644 --- a/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx +++ b/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx @@ -59,7 +59,7 @@ export const ActionPlanFormSummary: React.FC = React
diff --git a/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx b/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx index 168d69b7..5d518fd9 100644 --- a/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx +++ b/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx @@ -1,32 +1,43 @@ import React, { useCallback, useEffect } from "react"; import styled from "styled-components"; import i18n from "@eyeseetea/d2-ui-components/locales"; +import { EditOutlined } from "@material-ui/icons"; +import { CheckOutlined } from "@material-ui/icons"; +import BackupIcon from "@material-ui/icons/Backup"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; + import { Section } from "../../section/Section"; import { Box, Button, Typography } from "@material-ui/core"; import { UserCard } from "../../user-selector/UserCard"; import { RouteName, useRoutes } from "../../../hooks/useRoutes"; -import { EditOutlined } from "@material-ui/icons"; -import { CheckOutlined } from "@material-ui/icons"; import { Loader } from "../../loader/Loader"; -import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { FormSummaryData } from "../../../pages/event-tracker/useDiseaseOutbreakEvent"; import { Maybe } from "../../../../utils/ts-utils"; -import { FormType } from "../../../pages/form-page/FormPage"; import { Id } from "../../../../domain/entities/Ref"; import { GlobalMessage } from "../../../pages/form-page/useForm"; export type EventTrackerFormSummaryProps = { id: Id; - formType: FormType; + diseaseOutbreakFormType: "disease-outbreak-event"; + diseaseOutbreakCaseDataFormType: "disease-outbreak-event-case-data"; formSummary: Maybe; globalMessage: Maybe; - onOpenModal: () => void; + onCompleteClick: () => void; + isCasesDataUserDefined?: boolean; }; const ROW_COUNT = 3; export const EventTrackerFormSummary: React.FC = React.memo(props => { - const { id, formType, formSummary, onOpenModal: onCompleteClick, globalMessage } = props; + const { + id, + diseaseOutbreakCaseDataFormType, + diseaseOutbreakFormType, + formSummary, + onCompleteClick, + globalMessage, + isCasesDataUserDefined = false, + } = props; const { goTo } = useRoutes(); const snackbar = useSnackbar(); @@ -38,29 +49,44 @@ export const EventTrackerFormSummary: React.FC = R }, [globalMessage, goTo, snackbar]); const onEditClick = useCallback(() => { - goTo(RouteName.EDIT_FORM, { formType: formType, id: id }); - }, [formType, goTo, id]); - - const editButton = ( - - ); + goTo(RouteName.EDIT_FORM, { formType: diseaseOutbreakFormType, id: id }); + }, [diseaseOutbreakFormType, goTo, id]); + + const onEditCaseDataClick = useCallback(() => { + goTo(RouteName.EDIT_FORM, { formType: diseaseOutbreakCaseDataFormType, id: id }); + }, [diseaseOutbreakCaseDataFormType, goTo, id]); + + const headerButtons = ( + <> + + + {isCasesDataUserDefined ? ( + + ) : null} - const completeButton = ( - + + ); const getSummaryColumn = useCallback((index: number, label: string, value: string) => { @@ -79,16 +105,15 @@ export const EventTrackerFormSummary: React.FC = R
+ + {formSummary.incidentManager && ( + + )} + - - {formSummary.incidentManager && ( - - )} - {formSummary.summary.map((labelWithValue, index) => index < ROW_COUNT @@ -128,7 +153,7 @@ export const EventTrackerFormSummary: React.FC = R const SummaryContainer = styled.div` display: flex; flex-wrap: wrap; - width: max-content; + width: 100%; align-items: flex-start; margin-top: 0rem; @media (max-width: 1200px) { @@ -143,6 +168,11 @@ const SummaryColumn = styled.div` `; const StyledType = styled(Typography)` + margin-block-start: 10px; color: ${props => props.theme.palette.text.hint}; white-space: pre-line; `; + +const IncidentManagerContainer = styled.div` + margin-block-end: 10px; +`; diff --git a/src/webapp/components/import-file/Dropzone.tsx b/src/webapp/components/import-file/Dropzone.tsx new file mode 100644 index 00000000..7b5019b0 --- /dev/null +++ b/src/webapp/components/import-file/Dropzone.tsx @@ -0,0 +1,36 @@ +import React, { useImperativeHandle, useRef } from "react"; +import { DropzoneOptions, useDropzone } from "react-dropzone"; + +type DropzoneProps = DropzoneOptions & { + children?: React.ReactNode; + visible?: boolean; +}; + +export type CustomDropzoneRef = { + openDialog: () => void; +}; + +export const Dropzone = React.forwardRef( + (props: DropzoneProps, ref: React.ForwardedRef) => { + const childrenRef = useRef(null); + const { getRootProps, getInputProps, open } = useDropzone({ + noClick: !props.visible, + ...props, + }); + + useImperativeHandle(ref, () => ({ + openDialog() { + open(); + }, + })); + + return ( +
+ +
+ {props.children} +
+
+ ); + } +); diff --git a/src/webapp/components/import-file/ImportFile.tsx b/src/webapp/components/import-file/ImportFile.tsx new file mode 100644 index 00000000..7d0dacaa --- /dev/null +++ b/src/webapp/components/import-file/ImportFile.tsx @@ -0,0 +1,234 @@ +import React, { useCallback, useRef, useState } from "react"; +import styled from "styled-components"; +import i18n from "../../../utils/i18n"; +import { CustomDropzoneRef, Dropzone } from "./Dropzone"; +import BackupIcon from "@material-ui/icons/Backup"; +import CloseIcon from "@material-ui/icons/Close"; +import WarningIcon from "@material-ui/icons/Warning"; +import { Button } from "../button/Button"; +import { FileRejection } from "react-dropzone/."; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { FormHelperText, InputLabel, Link } from "@mui/material"; +import { Maybe } from "../../../utils/ts-utils"; +import { IconButton } from "../icon-button/IconButton"; +import { SimpleModal } from "../simple-modal/SimpleModal"; +import { readFile } from "../../pages/form-page/utils/FileHelper"; +import { SheetData } from "../form/FormFieldsState"; +import { Id } from "../../../domain/entities/Ref"; + +type ImportFileProps = { + id: string; + fileTemplate: Maybe; + file: Maybe; + fileId: Maybe; + onChange: (file: File | undefined, sheetsData: SheetData[] | undefined) => void; + label?: string; + placeholder?: string; + disabled?: boolean; + helperText?: string; + errorText?: string; + error?: boolean; + required?: boolean; + fileNameLabel?: string; +}; + +export const ImportFile: React.FC = React.memo(props => { + const { + file, + onChange, + label, + id, + helperText, + errorText, + error, + required, + fileTemplate, + fileId, + fileNameLabel, + } = props; + + const snackbar = useSnackbar(); + const dropzoneRef = useRef(null); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [openErrorsModal, setOpenErrorsModal] = useState(false); + + const openFileUploadDialog = useCallback(() => { + dropzoneRef.current?.openDialog(); + }, [dropzoneRef]); + + const onDropFile = useCallback( + (files: File[], rejections: FileRejection[]) => { + const handleFileUpload = async () => { + const uploadedFile = files[0]; + if (rejections.length > 0 || !uploadedFile) { + snackbar.error(i18n.t("Multiple uploads not allowed, please select one file")); + } else { + const spreadsheets = await readFile(uploadedFile); + onChange(uploadedFile, spreadsheets); + } + }; + + handleFileUpload().catch(error => { + snackbar.error(i18n.t("Error uploading file.")); + console.error("Error uploading file:", error); + }); + }, + [onChange, snackbar] + ); + + const onOpenConfirmationModalRemoveFile = useCallback(() => { + setOpenDeleteModal(true); + }, []); + + const onOpenErrorsModal = useCallback(() => { + setOpenErrorsModal(true); + }, []); + + const onConfirmRemoveFile = useCallback(() => { + onChange(undefined, undefined); + setOpenDeleteModal(false); + }, [onChange]); + + return ( + + {label && ( + + )} + + + + + + {error && !!errorText && ( + <> + } + ariaLabel="Show error messages" + onClick={onOpenErrorsModal} + /> + setOpenErrorsModal(false)} + title={i18n.t("Errors in file")} + closeLabel={i18n.t("Close")} + > + {openErrorsModal && {errorText}} + + + )} + + {fileTemplate && ( + + {i18n.t("Download empty template")} + + )} + + {file && ( + + } + ariaLabel="Delete current uploaded file" + onClick={onOpenConfirmationModalRemoveFile} + /> + + {fileId ? i18n.t("Download historical data") : file.name} + + setOpenDeleteModal(false)} + title={i18n.t("Confirm remove the file")} + closeLabel={i18n.t("Cancel")} + footerButtons={ + + } + > + {openDeleteModal && ( + {i18n.t(`Are you sure you want to remove the file?`)} + )} + + + )} + {helperText && ( + {helperText} + )} + + ); +}); + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const FlexContainer = styled.div<{ gap?: number }>` + display: flex; + align-items: center; + gap: ${props => (props.gap ? props.gap : 24)}px; + .errors-file { + color: ${props => props.theme.palette.common.red700}; + } +`; + +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 StyledFormHelperText = styled(FormHelperText)<{ error?: boolean }>` + color: ${props => + props.error ? props.theme.palette.common.red700 : props.theme.palette.common.grey700}; +`; + +const RemoveContainer = styled.div` + display: flex; + align-items: center; + margin-block-start: 5px; + .remove-file { + color: ${props => props.theme.palette.common.red700}; + } +`; + +const Text = styled.div` + font-weight: 400; + font-size: 0.875rem; + color: ${props => props.theme.palette.common.grey900}; +`; + +const ErrorsText = styled.div` + font-weight: 400; + font-size: 0.875rem; + white-space: pre-wrap; + color: ${props => props.theme.palette.common.red700}; +`; + +ImportFile.displayName = "ImportFile"; diff --git a/src/webapp/components/map/MapSection.tsx b/src/webapp/components/map/MapSection.tsx index 8c966ece..9fb5cdc6 100644 --- a/src/webapp/components/map/MapSection.tsx +++ b/src/webapp/components/map/MapSection.tsx @@ -7,6 +7,7 @@ import { useMap } from "./useMap"; import { MapKey } from "../../../domain/entities/MapConfig"; import LoaderContainer from "../loader/LoaderContainer"; import { useAppContext } from "../../contexts/app-context"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type MapSectionProps = { mapKey: MapKey; @@ -15,6 +16,7 @@ type MapSectionProps = { dateRangeFilter?: string[]; eventDiseaseCode?: string; eventHazardCode?: string; + casesDataSource?: CasesDataSource; }; export const MapSection: React.FC = React.memo(props => { @@ -26,13 +28,17 @@ export const MapSection: React.FC = React.memo(props => { dateRangeFilter, eventDiseaseCode, eventHazardCode, + casesDataSource, } = props; - const { orgUnits } = useAppContext(); + const { configurations } = useAppContext(); const snackbar = useSnackbar(); const allProvincesIds = useMemo( - () => orgUnits.filter(orgUnit => orgUnit.level === "Province").map(orgUnit => orgUnit.id), - [orgUnits] + () => + configurations.orgUnits + .filter(orgUnit => orgUnit.level === "Province") + .map(orgUnit => orgUnit.id), + [configurations.orgUnits] ); const { mapConfigState } = useMap({ @@ -43,6 +49,7 @@ export const MapSection: React.FC = React.memo(props => { dateRangeFilter: dateRangeFilter, eventDiseaseCode: eventDiseaseCode, eventHazardCode: eventHazardCode, + casesDataSource: casesDataSource, }); const baseUrl = `${api.baseUrl}/api/apps/zebra-custom-maps-app/index.html`; diff --git a/src/webapp/components/map/useMap.ts b/src/webapp/components/map/useMap.ts index 952506ae..453eed89 100644 --- a/src/webapp/components/map/useMap.ts +++ b/src/webapp/components/map/useMap.ts @@ -7,6 +7,7 @@ import { } from "../../../domain/entities/MapConfig"; import i18n from "../../../utils/i18n"; import { Maybe } from "../../../utils/ts-utils"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type LoadingState = { kind: "loading"; @@ -49,6 +50,7 @@ export function useMap(params: { allOrgUnitsIds: string[]; eventDiseaseCode?: string; eventHazardCode?: string; + casesDataSource?: CasesDataSource; dateRangeFilter?: string[]; singleSelectFilters?: Record; multiSelectFilters?: Record; @@ -58,6 +60,7 @@ export function useMap(params: { allOrgUnitsIds, eventDiseaseCode, eventHazardCode, + casesDataSource, dateRangeFilter, singleSelectFilters, multiSelectFilters, @@ -181,11 +184,16 @@ export function useMap(params: { ]); useEffect(() => { - if (mapKey === "event_tracker" && !eventDiseaseCode && !eventHazardCode) { + if ( + mapKey === "event_tracker" && + !eventDiseaseCode && + !eventHazardCode && + !casesDataSource + ) { return; } - compositionRoot.maps.getConfig.execute(mapKey).run( + compositionRoot.maps.getConfig.execute(mapKey, casesDataSource).run( config => { setMapProgramIndicators(config.programIndicators); setDefaultStartDate(config.startDate); @@ -233,7 +241,14 @@ export function useMap(params: { }); } ); - }, [compositionRoot.maps.getConfig, mapKey, allOrgUnitsIds, eventDiseaseCode, eventHazardCode]); + }, [ + compositionRoot.maps.getConfig, + mapKey, + allOrgUnitsIds, + eventDiseaseCode, + eventHazardCode, + casesDataSource, + ]); return { mapConfigState, diff --git a/src/webapp/components/section/Section.tsx b/src/webapp/components/section/Section.tsx index ff3a0495..0c2c5f05 100644 --- a/src/webapp/components/section/Section.tsx +++ b/src/webapp/components/section/Section.tsx @@ -8,8 +8,7 @@ type SectionProps = { title?: string; lastUpdated?: string; children: React.ReactNode; - headerButton?: React.ReactNode; - secondaryHeaderButton?: React.ReactNode; + headerButtons?: React.ReactNode; hasSeparator?: boolean; titleVariant?: "primary" | "secondary"; }; @@ -18,8 +17,7 @@ export const Section: React.FC = React.memo( ({ title = "", lastUpdated = "", - headerButton, - secondaryHeaderButton, + headerButtons, hasSeparator = false, children, titleVariant = "primary", @@ -42,10 +40,7 @@ export const Section: React.FC = React.memo( ) : null} - - {headerButton ?
{headerButton}
: null} - {secondaryHeaderButton ?
{secondaryHeaderButton}
: null} -
+ {headerButtons ? {headerButtons} : null} {children} @@ -65,6 +60,11 @@ const SectionContainer = styled.section<{ $hasSeparator?: boolean }>` const Header = styled.div` display: flex; justify-content: space-between; + @media (max-width: 700px) { + flex-direction: column; + align-items: flex-start; + gap: 24px; + } `; const TitleContainer = styled.div` diff --git a/src/webapp/contexts/app-context.ts b/src/webapp/contexts/app-context.ts index 8c226c8b..6c696cd2 100644 --- a/src/webapp/contexts/app-context.ts +++ b/src/webapp/contexts/app-context.ts @@ -2,7 +2,6 @@ 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"; import { Configurations } from "../../domain/entities/AppConfigurations"; export interface AppContextState { @@ -10,7 +9,6 @@ export interface AppContextState { isDev: boolean; currentUser: User; compositionRoot: CompositionRoot; - orgUnits: OrgUnit[]; configurations: Configurations; } diff --git a/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts b/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts index b1f5cfa1..4e91bb8f 100644 --- a/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts +++ b/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts @@ -15,6 +15,9 @@ export function useCheckWritePermission(formType: FormType) { case "disease-outbreak-event": snackbar.error("You do not have permission to create/edit events"); break; + case "disease-outbreak-event-case-data": + snackbar.error("You do not have permission to edit historical case data"); + break; case "incident-management-team-member-assignment": snackbar.error( "You do not have permission to create/edit IM team member assignments" diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index 9b50504f..f78cecf6 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -16,6 +16,7 @@ export enum RouteName { const formTypes = [ "disease-outbreak-event", + "disease-outbreak-event-case-data", "risk-assessment-grading", "risk-assessment-summary", "risk-assessment-questionnaire", diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 4d39c89a..fdb51904 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -35,7 +35,6 @@ 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 configurations = await compositionRoot.diseaseOutbreakEvent.getConfigurations .execute() @@ -47,7 +46,6 @@ function App(props: AppProps) { compositionRoot, isDev, api, - orgUnits, configurations, }); setShowShareButton(isShareButtonVisible); diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 788bebb1..5901bda0 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -23,6 +23,7 @@ import { StatsCard } from "../../components/stats-card/StatsCard"; import { useLastAnalyticsRuntime } from "../../hooks/useLastAnalyticsRuntime"; import { useOverviewCards } from "./useOverviewCards"; import { SimpleModal } from "../../components/simple-modal/SimpleModal"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; //TO DO : Create Risk assessment section export const riskAssessmentColumns: TableColumn[] = [ @@ -83,15 +84,19 @@ export const EventTrackerPage: React.FC = React.memo(() => { changeCurrentEventTracker(eventTrackerDetails); } }, [changeCurrentEventTracker, eventTrackerDetails]); - return (
@@ -114,13 +119,14 @@ export const EventTrackerPage: React.FC = React.memo(() => { eventDiseaseCode={currentEventTracker?.suspectedDiseaseCode} eventHazardCode={currentEventTracker?.hazardType} dateRangeFilter={dateRangeFilter.value || []} + casesDataSource={currentEventTracker?.casesDataSource} />
{ currentEventTracker?.suspectedDisease?.name || currentEventTracker?.hazardType } + casesDataSource={currentEventTracker?.casesDataSource} /> { currentEventTracker?.suspectedDisease?.name || currentEventTracker?.hazardType } + casesDataSource={currentEventTracker?.casesDataSource} />
{ - compositionRoot.diseaseOutbreakEvent.complete.execute(id).run( - () => { - const eventTrackerName = - eventTrackerDetails?.hazardType ?? eventTrackerDetails?.suspectedDisease?.name; + if (eventTrackerDetails) { + compositionRoot.diseaseOutbreakEvent.complete + .execute(eventTrackerDetails, configurations) + .run( + () => { + const eventTrackerName = + eventTrackerDetails?.hazardType ?? + eventTrackerDetails?.suspectedDisease?.name; - const updatedEventTrackerTypes = existingEventTrackerTypes.filter( - eventTrackerType => eventTrackerType !== eventTrackerName - ); + const updatedEventTrackerTypes = existingEventTrackerTypes.filter( + eventTrackerType => eventTrackerType !== eventTrackerName + ); - if (eventTrackerName) { - changeExistingEventTrackerTypes(updatedEventTrackerTypes); - } + if (eventTrackerName) { + changeExistingEventTrackerTypes(updatedEventTrackerTypes); + } - setGlobalMessage({ - type: "success", - text: `Event tracker with id: ${id} has been completed`, - }); - }, - err => { - console.error(err); - setGlobalMessage({ - type: "error", - text: `Failed to complete event: : ${err.message}`, - }); - } - ); + setGlobalMessage({ + type: "success", + text: `Event tracker with id: ${id} has been completed`, + }); + }, + err => { + console.error(err); + setGlobalMessage({ + type: "error", + text: `Failed to complete event: : ${err.message}`, + }); + } + ); + } }, [ changeExistingEventTrackerTypes, compositionRoot.diseaseOutbreakEvent.complete, - eventTrackerDetails?.hazardType, - eventTrackerDetails?.suspectedDisease?.name, + configurations, + eventTrackerDetails, existingEventTrackerTypes, id, ]); diff --git a/src/webapp/pages/event-tracker/useOverviewCards.ts b/src/webapp/pages/event-tracker/useOverviewCards.ts index 1ef02b7c..f3b13fdf 100644 --- a/src/webapp/pages/event-tracker/useOverviewCards.ts +++ b/src/webapp/pages/event-tracker/useOverviewCards.ts @@ -12,9 +12,11 @@ export function useOverviewCards() { useEffect(() => { const type = currentEventTracker?.suspectedDiseaseCode || currentEventTracker?.hazardType; - if (type) { + const casesDataSource = currentEventTracker?.casesDataSource; + + if (type && casesDataSource) { setIsLoading(true); - compositionRoot.performanceOverview.getOverviewCards.execute(type).run( + compositionRoot.performanceOverview.getOverviewCards.execute(type, casesDataSource).run( overviewCards => { setIsLoading(false); setOverviewCards(overviewCards); diff --git a/src/webapp/pages/form-page/FormPage.tsx b/src/webapp/pages/form-page/FormPage.tsx index 5408a886..51bef217 100644 --- a/src/webapp/pages/form-page/FormPage.tsx +++ b/src/webapp/pages/form-page/FormPage.tsx @@ -9,6 +9,7 @@ import styled from "styled-components"; export type FormType = | "disease-outbreak-event" + | "disease-outbreak-event-case-data" | "risk-assessment-grading" | "risk-assessment-questionnaire" | "risk-assessment-summary" @@ -29,6 +30,9 @@ export const FormPage: React.FC = React.memo(() => { globalMessage, formState, isLoading, + openModal, + modalData, + setOpenModal, handleFormChange, onPrimaryButtonClick, onCancelForm, @@ -55,6 +59,9 @@ export const FormPage: React.FC = React.memo(() => { errorLabels={formLabels?.errors} handleAddNew={handleAddNew} handleRemove={handleRemove} + openModal={openModal} + modalData={modalData} + setOpenModal={setOpenModal} /> ) : ( formState.message && {formState.message} diff --git a/src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts b/src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts new file mode 100644 index 00000000..6b74cdb1 --- /dev/null +++ b/src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts @@ -0,0 +1,259 @@ +import _ from "../../../../domain/entities/generic/Collection"; + +import { CaseData } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { ValidationError, ValidationErrorKey } from "../../../../domain/entities/ValidationError"; +import { FormFileFieldState, Row, SheetData } from "../../../components/form/FormFieldsState"; +import { doesColumnExist, formatDateToDateString } from "../utils/FileHelper"; +import { diseaseOutbreakEventFieldIds } from "./mapDiseaseOutbreakEventToInitialFormState"; +import { OrgUnit } from "../../../../domain/entities/OrgUnit"; +import { Username } from "../../../../domain/entities/User"; + +const REQUIRED_DATA_ENTRY_COLUMN_HEADERS = { + district: "District", + reportDate: "Report Date (YYYY-MM-DD)", + suspectedCases: "NUMBER OF SUSPECTED CASES", + probableCases: "NUMBER OF PROBABLE CASES", + confirmedCases: "NUMBER OF CONFIRMED CASES", + deaths: "NUMBER OF DEATHS", +} as const; + +const REQUIRED_METADATA_COLUMN_HEADERS = { + identifier: "Identifier", + type: "Type", + name: "Name", +} as const; + +const SHEET_NAMES = { + dataEntry: "Data entry", + metadata: "Metadata", +}; + +const file_headers_missing: ValidationErrorKey = "file_headers_missing"; +const file_dates_missing: ValidationErrorKey = "file_dates_missing"; +const file_dates_not_unique: ValidationErrorKey = "file_dates_not_unique"; +const file_data_not_number: ValidationErrorKey = "file_data_not_number"; +const file_org_units_incorrect: ValidationErrorKey = "file_org_units_incorrect"; + +export function validateCaseSheetData( + updatedField: FormFileFieldState, + orgUnits: OrgUnit[] +): ValidationError { + if ( + !updatedField.value || + updatedField.data?.length === 0 || + !updatedField.data?.find(sheetData => sheetData.name === SHEET_NAMES.dataEntry) + ) { + return { + property: diseaseOutbreakEventFieldIds.casesDataFile, + value: updatedField.value, + errors: ["file_missing"], + }; + } + + const dataEntrySheetData = updatedField.data?.find( + sheetData => sheetData.name === SHEET_NAMES.dataEntry + ); + const metadataSheetData = updatedField.data?.find( + sheetData => sheetData.name === SHEET_NAMES.metadata + ); + + if ( + !dataEntrySheetData || + !metadataSheetData || + dataEntrySheetData.headers.length === 0 || + dataEntrySheetData.rows.length === 0 || + metadataSheetData.headers.length === 0 || + metadataSheetData.rows.length === 0 + ) { + return { + property: diseaseOutbreakEventFieldIds.casesDataFile, + value: updatedField.value, + errors: ["file_empty"], + }; + } + + const casesDataEntryHeadersNotPresent = Object.values( + REQUIRED_DATA_ENTRY_COLUMN_HEADERS + ).filter(header => !doesColumnExist(dataEntrySheetData.headers || [], header)); + + const metadataHeadersNotPresent = Object.values(REQUIRED_METADATA_COLUMN_HEADERS).filter( + header => !doesColumnExist(metadataSheetData.headers || [], header) + ); + + const casesDataEntryRows = mapDistrictNamesToDistrictIdsInDataEntryRows( + dataEntrySheetData.rows, + metadataSheetData.rows + ); + + const allDistrictsAndDatesCombinationsInColumn = casesDataEntryRows.map(row => { + const reportDate = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.reportDate]; + const districtId = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]; + if (reportDate) { + return reportDate ? `${districtId}-${formatDateToDateString(reportDate)}` : undefined; + } + }); + + const allOrgUnitIds = orgUnits.map(orgUnit => orgUnit.id); + + const lineWithErrors = casesDataEntryRows.reduce( + (errors, row, index): Partial> => { + const orgUnitIncorrect = + !row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district] || + !allOrgUnitIds.includes(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district] || ""); + + const reportDate = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.reportDate]; + const reportDateString = reportDate ? formatDateToDateString(reportDate) : undefined; + const dateNotPresent = !reportDateString; + + const districtAndDateCombination = `${ + row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district] + }-${reportDateString}`; + + const repeatedDistrictDateCombination = + reportDateString && + allDistrictsAndDatesCombinationsInColumn.indexOf(districtAndDateCombination) !== + index; + + const suspectedNotPresent = isNaN( + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.suspectedCases]) + ); + const probableNotPresent = isNaN( + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.probableCases]) + ); + const confirmedNotPresent = isNaN( + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.confirmedCases]) + ); + const deathsNotPresent = isNaN(Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.deaths])); + + return { + [file_org_units_incorrect]: orgUnitIncorrect + ? [...(errors[file_org_units_incorrect] || []), index + 2] + : errors[file_org_units_incorrect], + [file_dates_missing]: dateNotPresent + ? [...(errors[file_dates_missing] || []), index + 2] + : errors[file_dates_missing], + [file_dates_not_unique]: repeatedDistrictDateCombination + ? [...(errors[file_dates_not_unique] || []), index + 2] + : errors[file_dates_not_unique], + [file_data_not_number]: + suspectedNotPresent || + probableNotPresent || + confirmedNotPresent || + deathsNotPresent + ? [...(errors[file_data_not_number] || []), index + 2] + : errors[file_data_not_number], + }; + }, + { + [file_org_units_incorrect]: [], + [file_dates_missing]: [], + [file_dates_not_unique]: [], + [file_data_not_number]: [], + } as Partial> + ); + + return { + property: diseaseOutbreakEventFieldIds.casesDataFile, + value: updatedField.value, + errors: [], + errorsInFile: { + [file_headers_missing]: casesDataEntryHeadersNotPresent.length + ? `${casesDataEntryHeadersNotPresent.join( + ", " + )} headers in Data entry sheet are missing. Correct headers are: ${Object.values( + REQUIRED_DATA_ENTRY_COLUMN_HEADERS + ).join(", ")}` + : undefined, + [file_headers_missing]: metadataHeadersNotPresent.length + ? `${metadataHeadersNotPresent.join( + ", " + )} headers in Metadata sheet are missing. Correct headers are: ${Object.values( + REQUIRED_METADATA_COLUMN_HEADERS + ).join(", ")}` + : undefined, + [file_org_units_incorrect]: lineWithErrors[file_org_units_incorrect]?.length + ? `Org unit id is incorrect in row(s): ${lineWithErrors[ + file_org_units_incorrect + ]?.join()}` + : undefined, + [file_dates_missing]: lineWithErrors[file_dates_missing]?.length + ? `Date is missing in row(s): ${lineWithErrors[file_dates_missing]?.join()}` + : undefined, + [file_dates_not_unique]: lineWithErrors[file_dates_not_unique]?.length + ? `Date is repeated in row(s): ${lineWithErrors[file_dates_not_unique]?.join()}` + : undefined, + [file_data_not_number]: lineWithErrors[file_data_not_number]?.length + ? `Data is not a number in row(s): ${lineWithErrors[file_data_not_number]?.join()}` + : undefined, + }, + }; +} + +export function mapDistrictNamesToDistrictIdsInDataEntryRows( + dataEntryRows: Row[], + metadataRows: Row[] +): Row[] { + return dataEntryRows.map(row => { + const districtName = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]; + const districtId = metadataRows.find( + metadataRow => metadataRow[REQUIRED_METADATA_COLUMN_HEADERS.name] === districtName + )?.[REQUIRED_METADATA_COLUMN_HEADERS.identifier]; + + return { + ...row, + [REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]: districtId || "", + }; + }); +} + +export function getCaseDataFromField( + uploadedCasesSheetData: SheetData[], + currentUsername: Username +): CaseData[] { + const dataEntrySheetData = uploadedCasesSheetData.find( + sheetData => sheetData.name === SHEET_NAMES.dataEntry + ); + const metadataSheetData = uploadedCasesSheetData.find( + sheetData => sheetData.name === SHEET_NAMES.metadata + ); + + if ( + !dataEntrySheetData?.headers || + !dataEntrySheetData?.rows || + !metadataSheetData?.rows || + !metadataSheetData?.headers + ) { + throw new Error("Case data file is missing"); + } + + const casesDataEntryRows = mapDistrictNamesToDistrictIdsInDataEntryRows( + dataEntrySheetData.rows, + metadataSheetData.rows + ); + + const casesData: CaseData[] = casesDataEntryRows + .map(row => { + const reportDate = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.reportDate]; + const reportDateString = reportDate ? formatDateToDateString(reportDate) : undefined; + + const districtId = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]; + + if (reportDateString && districtId) { + return { + updatedBy: currentUsername, + reportDate: reportDateString, + orgUnit: districtId, + suspectedCases: + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.suspectedCases]) || 0, + probableCases: + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.probableCases]) || 0, + confirmedCases: + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.confirmedCases]) || 0, + deaths: Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.deaths]) || 0, + }; + } + }) + .filter((caseData): caseData is CaseData => caseData !== undefined); + + return casesData; +} diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts index 6436c3fa..5603d88d 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts @@ -1,6 +1,9 @@ import i18n from "@eyeseetea/d2-ui-components/locales"; import { DiseaseOutbreakEventFormData } from "../../../../domain/entities/ConfigurableForm"; -import { DataSource } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DataSource, +} from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { TeamMember } from "../../../../domain/entities/incident-management-team/TeamMember"; import { getFieldIdFromIdsDictionary } from "../../../components/form/FormFieldsState"; import { FormSectionState } from "../../../components/form/FormSectionsState"; @@ -15,6 +18,8 @@ import { export const diseaseOutbreakEventFieldIds = { name: "name", + casesDataSource: "casesDataSource", + casesDataFile: "casesDataFile", dataSource: "dataSource", hazardType: "hazardType", mainSyndromeCode: "mainSyndromeCode", @@ -61,6 +66,8 @@ export function mapTeamMemberToUser(teamMember: TeamMember): User { } type MainSectionKeys = | "name" + | "casesDataSource" + | "casesDataFile" | "dataSource" | "hazardType" | "mainSyndrome" @@ -90,7 +97,28 @@ export function mapDiseaseOutbreakEventToInitialFormState( editMode: boolean, existingEventTrackerTypes: (DiseaseNames | HazardNames)[] ): FormState { - const { entity: diseaseOutbreakEvent, options } = diseaseOutbreakEventWithOptions; + return diseaseOutbreakEventWithOptions.type === "disease-outbreak-event" + ? getInitialFormStateForDiseaseOutbreakEvent( + diseaseOutbreakEventWithOptions, + editMode, + existingEventTrackerTypes + ) + : getInitialFormStateForDiseaseOutbreakCaseData(diseaseOutbreakEventWithOptions); +} + +function getInitialFormStateForDiseaseOutbreakEvent( + diseaseOutbreakEventWithOptions: DiseaseOutbreakEventFormData, + editMode: boolean, + existingEventTrackerTypes: (DiseaseNames | HazardNames)[] +): FormState { + const { + entity: diseaseOutbreakEvent, + options, + caseDataFileTemplete, + uploadedCasesDataFile, + uploadedCasesDataFileId, + } = diseaseOutbreakEventWithOptions; + const { dataSources, incidentManagers, @@ -99,6 +127,7 @@ export function mapDiseaseOutbreakEventToInitialFormState( suspectedDiseases, notificationSources, incidentStatus, + casesDataSource, } = options; //If An Event Tracker has already been created for a given suspected disease or harzd type, @@ -112,6 +141,7 @@ export function mapDiseaseOutbreakEventToInitialFormState( const teamMemberOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); const dataSourcesOptions: PresentationOption[] = mapToPresentationOptions(dataSources); + const casesDataSourceOptions: PresentationOption[] = mapToPresentationOptions(casesDataSource); const hazardTypesOptions: PresentationOption[] = mapToPresentationOptions(filteredHazardTypes); const mainSyndromesOptions: PresentationOption[] = mapToPresentationOptions(mainSyndromes); const suspectedDiseasesOptions: PresentationOption[] = @@ -124,6 +154,9 @@ export function mapDiseaseOutbreakEventToInitialFormState( diseaseOutbreakEvent?.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; const isIBSDataSource = diseaseOutbreakEvent?.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS; + const isCasesDataUserDefined = + diseaseOutbreakEvent?.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; const fromIdsDictionary = (key: keyof typeof diseaseOutbreakEventFieldIds) => getFieldIdFromIdsDictionary(key, diseaseOutbreakEventFieldIds); @@ -373,6 +406,58 @@ export function mapDiseaseOutbreakEventToInitialFormState( }, ], }, + casesDataSource: { + title: "Cases Data Source", + id: "casesDataSource_section", + isVisible: true, + required: true, + fields: [ + { + id: fromIdsDictionary("casesDataSource"), + placeholder: "Select the cases data source", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: casesDataSourceOptions, + value: diseaseOutbreakEvent?.casesDataSource || "", + width: "300px", + required: true, + showIsRequired: false, + disabled: editMode && isCasesDataUserDefined, + }, + ], + }, + casesDataFile: { + title: "Cases Data File", + id: "casesDataFile_section", + isVisible: isCasesDataUserDefined, + required: true, + fields: [ + { + id: fromIdsDictionary("casesDataFile"), + isVisible: isCasesDataUserDefined, + errors: [], + type: "file", + value: isCasesDataUserDefined ? uploadedCasesDataFile : undefined, + required: true, + showIsRequired: false, + data: undefined, + fileId: isCasesDataUserDefined ? uploadedCasesDataFileId : undefined, + fileTemplate: caseDataFileTemplete, + fileNameLabel: + isCasesDataUserDefined && uploadedCasesDataFileId + ? i18n.t("HISTORICAL_CASE_DATA") + : undefined, + helperText: + editMode && isCasesDataUserDefined + ? i18n.t( + "In order to add or replace cases, you need to download the current file and add the new ones." + ) + : i18n.t("Please, download the template and add the required data."), + }, + ], + }, dataSource: { title: "Event Source", id: "dataSource_section", @@ -650,6 +735,8 @@ export function mapDiseaseOutbreakEventToInitialFormState( isValid: false, sections: [ mainSections.name, + mainSections.casesDataSource, + mainSections.casesDataFile, mainSections.dataSource, mainSections.hazardType, mainSections.suspectedDisease, @@ -665,3 +752,50 @@ export function mapDiseaseOutbreakEventToInitialFormState( ], }; } + +function getInitialFormStateForDiseaseOutbreakCaseData( + diseaseOutbreakEventWithOptions: DiseaseOutbreakEventFormData +): FormState { + const { + entity: diseaseOutbreakEvent, + caseDataFileTemplete, + uploadedCasesDataFile, + uploadedCasesDataFileId, + } = diseaseOutbreakEventWithOptions; + + return { + id: diseaseOutbreakEvent?.id || "", + title: "Event cases data", + saveButtonLabel: "Save", + isValid: false, + sections: [ + { + title: "Cases Data File", + id: "casesDataFile_section", + isVisible: true, + required: true, + fields: [ + { + id: "casesDataFile", + isVisible: true, + errors: [], + type: "file", + value: uploadedCasesDataFile, + required: true, + showIsRequired: false, + data: undefined, + fileId: uploadedCasesDataFileId, + fileTemplate: caseDataFileTemplete, + fileNameLabel: uploadedCasesDataFileId + ? i18n.t("HISTORICAL_CASE_DATA") + : undefined, + + helperText: i18n.t( + "In order to add or replace cases, you need to download the current file and add the new ones." + ), + }, + ], + }, + ], + }; +} diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent.ts new file mode 100644 index 00000000..a3369e0c --- /dev/null +++ b/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent.ts @@ -0,0 +1,260 @@ +import { DiseaseOutbreakEventFormData } from "../../../../domain/entities/ConfigurableForm"; +import { + DiseaseOutbreakEvent, + DataSource, + HazardType, + NationalIncidentStatus, + CasesDataSource, + DiseaseOutbreakEventBaseAttrs, +} from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Maybe } from "../../../../utils/ts-utils"; +import { + FormFieldState, + getAllFieldsFromSections, + getStringFieldValue, + getMultipleOptionsFieldValue, + getDateFieldValue, + getBooleanFieldValue, + getFieldFileDataById, +} from "../../../components/form/FormFieldsState"; +import { FormState } from "../../../components/form/FormState"; +import { getCaseDataFromField } from "./CaseDataFileFieldHelper"; +import { diseaseOutbreakEventFieldIds } from "./mapDiseaseOutbreakEventToInitialFormState"; + +export function mapFormStateToDiseaseOutbreakEvent( + formState: FormState, + currentUserName: string, + formData: DiseaseOutbreakEventFormData +): DiseaseOutbreakEvent { + const { entity: diseaseOutbreakEvent, type: formType } = formData; + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + + return formType === "disease-outbreak-event" + ? getDiseaseOutbreakEventFromDiseaseOutbreakForm( + currentUserName, + diseaseOutbreakEvent, + allFields + ) + : getDiseaseOutbreakEventFromDiseaseOutbreakCaseDataForm( + currentUserName, + diseaseOutbreakEvent, + allFields + ); +} + +function getDiseaseOutbreakEventFromDiseaseOutbreakForm( + currentUserName: string, + diseaseOutbreakEvent: Maybe, + allFields: FormFieldState[] +): DiseaseOutbreakEvent { + const diseaseOutbreakEventEditableData = { + name: getStringFieldValue(diseaseOutbreakEventFieldIds.name, allFields), + dataSource: getStringFieldValue( + diseaseOutbreakEventFieldIds.dataSource, + allFields + ) as DataSource, + hazardType: getStringFieldValue( + diseaseOutbreakEventFieldIds.hazardType, + allFields + ) as HazardType, + mainSyndromeCode: getStringFieldValue( + diseaseOutbreakEventFieldIds.mainSyndromeCode, + allFields + ), + suspectedDiseaseCode: getStringFieldValue( + diseaseOutbreakEventFieldIds.suspectedDiseaseCode, + allFields + ), + notificationSourceCode: getStringFieldValue( + diseaseOutbreakEventFieldIds.notificationSourceCode, + allFields + ), + areasAffectedProvinceIds: getMultipleOptionsFieldValue( + diseaseOutbreakEventFieldIds.areasAffectedProvinceIds, + allFields + ), + areasAffectedDistrictIds: getMultipleOptionsFieldValue( + diseaseOutbreakEventFieldIds.areasAffectedDistrictIds, + allFields + ), + incidentStatus: getStringFieldValue( + diseaseOutbreakEventFieldIds.incidentStatus, + allFields + ) as NationalIncidentStatus, + emerged: { + date: getDateFieldValue(diseaseOutbreakEventFieldIds.emergedDate, allFields) as Date, + narrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.emergedNarrative, + allFields + ), + }, + detected: { + date: getDateFieldValue(diseaseOutbreakEventFieldIds.detectedDate, allFields) as Date, + narrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.detectedNarrative, + allFields + ), + }, + notified: { + date: getDateFieldValue(diseaseOutbreakEventFieldIds.notifiedDate, allFields) as Date, + narrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.notifiedNarrative, + allFields + ), + }, + earlyResponseActions: { + initiateInvestigation: getDateFieldValue( + diseaseOutbreakEventFieldIds.initiateInvestigation, + allFields + ) as Date, + conductEpidemiologicalAnalysis: getDateFieldValue( + diseaseOutbreakEventFieldIds.conductEpidemiologicalAnalysis, + allFields + ) as Date, + laboratoryConfirmation: getDateFieldValue( + diseaseOutbreakEventFieldIds.laboratoryConfirmation, + allFields + ) as Date, + appropriateCaseManagement: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.appropriateCaseManagementDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.appropriateCaseManagementNA, + allFields + ), + }, + initiatePublicHealthCounterMeasures: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresNA, + allFields + ), + }, + initiateRiskCommunication: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.initiateRiskCommunicationDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.initiateRiskCommunicationNA, + allFields + ), + }, + establishCoordination: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.establishCoordinationDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.establishCoordinationNa, + allFields + ), + }, + responseNarrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.responseNarrative, + allFields + ), + }, + incidentManagerName: getStringFieldValue( + diseaseOutbreakEventFieldIds.incidentManagerName, + allFields + ), + notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), + casesDataSource: getStringFieldValue( + diseaseOutbreakEventFieldIds.casesDataSource, + allFields + ) as CasesDataSource, + }; + + const isCasesDataUserDefined = + diseaseOutbreakEventEditableData.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + const uploadedCasesSheetData = isCasesDataUserDefined + ? getFieldFileDataById(diseaseOutbreakEventFieldIds.casesDataFile, allFields) + : undefined; + + const hasCasesDataChange = isCasesDataUserDefined && uploadedCasesSheetData; + + const casesData = hasCasesDataChange + ? getCaseDataFromField(uploadedCasesSheetData, currentUserName) + : diseaseOutbreakEvent?.uploadedCasesData; + + const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { + id: diseaseOutbreakEvent?.id || "", + status: diseaseOutbreakEvent?.status || "ACTIVE", + created: diseaseOutbreakEvent?.created, + lastUpdated: diseaseOutbreakEvent?.lastUpdated, + createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, + ...diseaseOutbreakEventEditableData, + }; + const newDiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + uploadedCasesData: undefined, + + // NOTICE: Not needed but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); + + return casesData + ? newDiseaseOutbreakEvent.addUploadedCasesData(casesData) + : newDiseaseOutbreakEvent; +} + +function getDiseaseOutbreakEventFromDiseaseOutbreakCaseDataForm( + currentUserName: string, + diseaseOutbreakEvent: Maybe, + allFields: FormFieldState[] +): DiseaseOutbreakEvent { + if (!diseaseOutbreakEvent) { + throw new Error("Disease Outbreak Event is required"); + } + + const isCasesDataUserDefined = + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + const uploadedCasesSheetData = isCasesDataUserDefined + ? getFieldFileDataById(diseaseOutbreakEventFieldIds.casesDataFile, allFields) + : undefined; + + const hasCasesDataChange = isCasesDataUserDefined && uploadedCasesSheetData; + + const casesData = hasCasesDataChange + ? getCaseDataFromField(uploadedCasesSheetData, currentUserName) + : diseaseOutbreakEvent.uploadedCasesData; + + const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { + ...diseaseOutbreakEvent, + }; + const newDiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + uploadedCasesData: undefined, + + // NOTICE: Not needed but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); + + return casesData + ? newDiseaseOutbreakEvent.addUploadedCasesData(casesData) + : newDiseaseOutbreakEvent; +} diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts deleted file mode 100644 index ea0f52b1..00000000 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - dataSourceMap, - getHazardTypeFromString, - incidentStatusMap, -} from "../../../../data/repositories/consts/DiseaseOutbreakConstants"; -import { DiseaseOutbreakEventFormData } from "../../../../domain/entities/ConfigurableForm"; -import { DiseaseOutbreakEventBaseAttrs } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { - FormFieldState, - getAllFieldsFromSections, - getBooleanFieldValue, - getDateFieldValue, - getMultipleOptionsFieldValue, - getStringFieldValue, -} from "../../../components/form/FormFieldsState"; -import { FormState } from "../../../components/form/FormState"; -import { diseaseOutbreakEventFieldIds } from "./mapDiseaseOutbreakEventToInitialFormState"; - -type DateFieldIdsToValidate = - | "emergedDate" - | "detectedDate" - | "notifiedDate" - | "initiateInvestigation" - | "conductEpidemiologicalAnalysis" - | "laboratoryConfirmation"; - -export function mapFormStateToDiseaseOutbreakEventData( - formState: FormState, - currentUserName: string, - configurableDiseaseOutbreakEventForm: DiseaseOutbreakEventFormData -): DiseaseOutbreakEventBaseAttrs { - const diseaseOutbreakEvent = configurableDiseaseOutbreakEventForm.entity; - const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); - - const dataSource = - dataSourceMap[getStringFieldValue(diseaseOutbreakEventFieldIds.dataSource, allFields)]; - - const incidentStatus = - incidentStatusMap[ - getStringFieldValue(diseaseOutbreakEventFieldIds.incidentStatus, allFields) - ]; - - if (!dataSource || !incidentStatus) - throw new Error(`Data source or incident status not valid.`); - - const dateValuesByFieldId = getValidDateValuesByFieldIdFromFields(allFields); - - const diseaseOutbreakEventEditableData = { - name: getStringFieldValue(diseaseOutbreakEventFieldIds.name, allFields), - dataSource: dataSource, - hazardType: getHazardTypeFromString( - getStringFieldValue(diseaseOutbreakEventFieldIds.hazardType, allFields) - ), - mainSyndromeCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.mainSyndromeCode, - allFields - ), - suspectedDiseaseCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.suspectedDiseaseCode, - allFields - ), - notificationSourceCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.notificationSourceCode, - allFields - ), - areasAffectedProvinceIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedProvinceIds, - allFields - ), - areasAffectedDistrictIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedDistrictIds, - allFields - ), - incidentStatus: incidentStatus, - emerged: { - date: dateValuesByFieldId.emergedDate, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.emergedNarrative, - allFields - ), - }, - detected: { - date: dateValuesByFieldId.detectedDate, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.detectedNarrative, - allFields - ), - }, - notified: { - date: dateValuesByFieldId.notifiedDate, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.notifiedNarrative, - allFields - ), - }, - earlyResponseActions: { - initiateInvestigation: dateValuesByFieldId.initiateInvestigation, - conductEpidemiologicalAnalysis: dateValuesByFieldId.conductEpidemiologicalAnalysis, - laboratoryConfirmation: dateValuesByFieldId.laboratoryConfirmation, - appropriateCaseManagement: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementNA, - allFields - ), - }, - initiatePublicHealthCounterMeasures: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresNA, - allFields - ), - }, - initiateRiskCommunication: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationNA, - allFields - ), - }, - establishCoordination: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationNa, - allFields - ), - }, - responseNarrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.responseNarrative, - allFields - ), - }, - incidentManagerName: getStringFieldValue( - diseaseOutbreakEventFieldIds.incidentManagerName, - allFields - ), - notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), - }; - - const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { - id: diseaseOutbreakEvent?.id || "", - status: diseaseOutbreakEvent?.status || "ACTIVE", - created: diseaseOutbreakEvent?.created, - lastUpdated: diseaseOutbreakEvent?.lastUpdated, - createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, - ...diseaseOutbreakEventEditableData, - }; - - return diseaseOutbreakEventBase; -} - -function getValidDateValuesByFieldIdFromFields( - allFields: FormFieldState[] -): Record { - const getFromAllFields = (fieldId: keyof typeof diseaseOutbreakEventFieldIds): Date => { - const maybeDate = getDateFieldValue(fieldId, allFields); - - if (maybeDate === null) { - throw new Error(`Invalid date value.`); - } else { - return maybeDate; - } - }; - - return { - emergedDate: getFromAllFields(diseaseOutbreakEventFieldIds.emergedDate), - detectedDate: getFromAllFields(diseaseOutbreakEventFieldIds.detectedDate), - notifiedDate: getFromAllFields(diseaseOutbreakEventFieldIds.notifiedDate), - initiateInvestigation: getFromAllFields(diseaseOutbreakEventFieldIds.initiateInvestigation), - conductEpidemiologicalAnalysis: getFromAllFields( - diseaseOutbreakEventFieldIds.conductEpidemiologicalAnalysis - ), - laboratoryConfirmation: getFromAllFields( - diseaseOutbreakEventFieldIds.laboratoryConfirmation - ), - }; -} diff --git a/src/webapp/pages/form-page/disease-outbreak-event/useDiseaseOutbreakEventForm.ts b/src/webapp/pages/form-page/disease-outbreak-event/useDiseaseOutbreakEventForm.ts new file mode 100644 index 00000000..c130a560 --- /dev/null +++ b/src/webapp/pages/form-page/disease-outbreak-event/useDiseaseOutbreakEventForm.ts @@ -0,0 +1,192 @@ +import { useCallback } from "react"; + +import i18n from "../../../../utils/i18n"; +import { useAppContext } from "../../../contexts/app-context"; +import { Id } from "../../../../domain/entities/Ref"; +import { RouteName, useRoutes } from "../../../hooks/useRoutes"; +import { ConfigurableForm } from "../../../../domain/entities/ConfigurableForm"; +import { ModalData } from "../../../components/form/Form"; +import { + CasesDataSource, + DiseaseOutbreakEvent, +} from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { GlobalMessage } from "../useForm"; + +type State = { + onSaveDiseaseOutbreakEvent: (formDataWithEntityData: ConfigurableForm) => void; +}; + +export function useDiseaseOutbreakEventForm(params: { + editMode: boolean; + setIsLoading: (isLoading: boolean) => void; + setGlobalMessage: (message: GlobalMessage) => void; + setOpenModal: (openModal: boolean) => void; + setModalData: (modalData: ModalData) => void; +}): State { + const { editMode, setIsLoading, setGlobalMessage, setOpenModal, setModalData } = params; + + const { compositionRoot, configurations } = useAppContext(); + const { goTo } = useRoutes(); + + const onMapDiseaseOutbreakEventToAlerts = useCallback( + (diseaseOutbreakEventId: Id, entity: DiseaseOutbreakEvent) => { + const { eventTrackerConfigurations } = configurations.selectableOptions; + + compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts + .execute( + diseaseOutbreakEventId, + entity, + eventTrackerConfigurations.hazardTypes, + eventTrackerConfigurations.suspectedDiseases + ) + .run( + () => {}, + err => { + console.error({ err }); + } + ); + goTo(RouteName.EVENT_TRACKER, { + id: diseaseOutbreakEventId, + }); + setGlobalMessage({ + text: i18n.t(`Disease Outbreak saved successfully`), + type: "success", + }); + }, + [ + compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts, + configurations.selectableOptions, + goTo, + setGlobalMessage, + ] + ); + + const onSaveDiseaseOutbreakEventWithCaseData = useCallback( + (formDataWithEntityData: ConfigurableForm) => { + if ( + formDataWithEntityData.type === "disease-outbreak-event" || + formDataWithEntityData.type === "disease-outbreak-event-case-data" + ) { + setIsLoading(true); + compositionRoot.save.execute(formDataWithEntityData, configurations, editMode).run( + diseaseOutbreakEventId => { + setIsLoading(false); + + if ( + diseaseOutbreakEventId && + formDataWithEntityData.entity && + formDataWithEntityData.type === "disease-outbreak-event" + ) { + onMapDiseaseOutbreakEventToAlerts( + diseaseOutbreakEventId, + formDataWithEntityData.entity + ); + } else if ( + diseaseOutbreakEventId && + formDataWithEntityData.type === "disease-outbreak-event-case-data" + ) { + goTo(RouteName.EVENT_TRACKER, { + id: diseaseOutbreakEventId, + }); + setGlobalMessage({ + text: i18n.t(`Disease outbreak case data saved successfully`), + type: "success", + }); + } + }, + err => { + setGlobalMessage({ + text: i18n.t( + formDataWithEntityData.type === "disease-outbreak-event-case-data" + ? `Error saving disease outbreak case data: ${err.message}` + : `Error saving disease outbreak: ${err.message}` + ), + type: "error", + }); + } + ); + } + }, + [ + compositionRoot.save, + configurations, + editMode, + goTo, + onMapDiseaseOutbreakEventToAlerts, + setGlobalMessage, + setIsLoading, + ] + ); + + const onSaveDiseaseOutbreakEvent = useCallback( + (formDataWithEntityData: ConfigurableForm) => { + if ( + !formDataWithEntityData.entity || + (formDataWithEntityData.type !== "disease-outbreak-event-case-data" && + formDataWithEntityData.type !== "disease-outbreak-event") + ) + return; + + const haveChangedCasesDataInDiseaseOutbreak = + editMode && + formDataWithEntityData.type === "disease-outbreak-event" && + !formDataWithEntityData.uploadedCasesDataFileId && + !!formDataWithEntityData.uploadedCasesDataFile && + formDataWithEntityData.entity?.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + if ( + haveChangedCasesDataInDiseaseOutbreak || + formDataWithEntityData.type === "disease-outbreak-event-case-data" + ) { + setOpenModal(true); + setModalData({ + title: i18n.t("Warning"), + content: i18n.t( + "You have uploaded a new data cases file. This action will replace the current data of this disease outbreak event with the data of the file. Are you sure you want to continue?" + ), + cancelLabel: i18n.t("Cancel"), + confirmLabel: i18n.t("Save"), + onConfirm: () => { + onSaveDiseaseOutbreakEventWithCaseData(formDataWithEntityData); + }, + }); + } else { + setIsLoading(true); + compositionRoot.save.execute(formDataWithEntityData, configurations, editMode).run( + diseaseOutbreakEventId => { + setIsLoading(false); + + if (diseaseOutbreakEventId && formDataWithEntityData.entity) { + onMapDiseaseOutbreakEventToAlerts( + diseaseOutbreakEventId, + formDataWithEntityData.entity + ); + } + }, + err => { + setGlobalMessage({ + text: i18n.t(`Error saving disease outbreak: ${err.message}`), + type: "error", + }); + } + ); + } + }, + [ + compositionRoot.save, + configurations, + editMode, + onMapDiseaseOutbreakEventToAlerts, + onSaveDiseaseOutbreakEventWithCaseData, + setGlobalMessage, + setIsLoading, + setModalData, + setOpenModal, + ] + ); + + return { + onSaveDiseaseOutbreakEvent, + }; +} diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index 386ecb7f..ba181d75 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -31,6 +31,7 @@ export function mapEntityToFormState(options: { switch (configurableForm.type) { case "disease-outbreak-event": + case "disease-outbreak-event-case-data": return mapDiseaseOutbreakEventToInitialFormState( configurableForm, editMode ?? false, diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 8f360a09..c6194feb 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -1,17 +1,10 @@ -import { - DataSource, - DiseaseOutbreakEventBaseAttrs, - HazardType, - NationalIncidentStatus, -} from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { FormState } from "../../components/form/FormState"; import { diseaseOutbreakEventFieldIds } from "./disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState"; import { FormFieldState, getAllFieldsFromSections, - getBooleanFieldValue, - getDateFieldValue, - getMultipleOptionsFieldValue, + getFieldFileIdById, + getFileFieldValue, getStringFieldValue, } from "../../components/form/FormFieldsState"; import { @@ -26,7 +19,6 @@ import { ResponseActionFormData, SingleResponseActionFormData, } from "../../../domain/entities/ConfigurableForm"; -import { Maybe } from "../../../utils/ts-utils"; import { RiskAssessmentGrading } from "../../../domain/entities/risk-assessment/RiskAssessmentGrading"; import { riskAssessmentGradingCodes, @@ -53,6 +45,7 @@ import { import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { mapFormStateToDiseaseOutbreakEvent } from "./disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent"; export function mapFormStateToEntityData( formState: FormState, @@ -60,15 +53,29 @@ export function mapFormStateToEntityData( formData: ConfigurableForm ): ConfigurableForm { switch (formData.type) { - case "disease-outbreak-event": { + case "disease-outbreak-event": + case "disease-outbreak-event-case-data": { const dieaseEntity = mapFormStateToDiseaseOutbreakEvent( formState, currentUserName, - formData.entity + formData + ); + + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + const uploadedCasesDataFileValue = getFileFieldValue( + diseaseOutbreakEventFieldIds.casesDataFile, + allFields + ); + const uploadedCasesDataFileId = getFieldFileIdById( + diseaseOutbreakEventFieldIds.casesDataFile, + allFields ); + const diseaseForm: DiseaseOutbreakEventFormData = { ...formData, entity: dieaseEntity, + uploadedCasesDataFile: uploadedCasesDataFileValue, + uploadedCasesDataFileId: uploadedCasesDataFileId, }; return diseaseForm; } @@ -142,145 +149,6 @@ export function mapFormStateToEntityData( } } -function mapFormStateToDiseaseOutbreakEvent( - formState: FormState, - currentUserName: string, - diseaseOutbreakEvent: Maybe -): DiseaseOutbreakEventBaseAttrs { - const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); - - const diseaseOutbreakEventEditableData = { - name: getStringFieldValue(diseaseOutbreakEventFieldIds.name, allFields), - dataSource: getStringFieldValue( - diseaseOutbreakEventFieldIds.dataSource, - allFields - ) as DataSource, - hazardType: getStringFieldValue( - diseaseOutbreakEventFieldIds.hazardType, - allFields - ) as HazardType, - mainSyndromeCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.mainSyndromeCode, - allFields - ), - suspectedDiseaseCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.suspectedDiseaseCode, - allFields - ), - notificationSourceCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.notificationSourceCode, - allFields - ), - areasAffectedProvinceIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedProvinceIds, - allFields - ), - areasAffectedDistrictIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedDistrictIds, - allFields - ), - incidentStatus: getStringFieldValue( - diseaseOutbreakEventFieldIds.incidentStatus, - allFields - ) as NationalIncidentStatus, - emerged: { - date: getDateFieldValue(diseaseOutbreakEventFieldIds.emergedDate, allFields) as Date, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.emergedNarrative, - allFields - ), - }, - detected: { - date: getDateFieldValue(diseaseOutbreakEventFieldIds.detectedDate, allFields) as Date, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.detectedNarrative, - allFields - ), - }, - notified: { - date: getDateFieldValue(diseaseOutbreakEventFieldIds.notifiedDate, allFields) as Date, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.notifiedNarrative, - allFields - ), - }, - earlyResponseActions: { - initiateInvestigation: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiateInvestigation, - allFields - ) as Date, - conductEpidemiologicalAnalysis: getDateFieldValue( - diseaseOutbreakEventFieldIds.conductEpidemiologicalAnalysis, - allFields - ) as Date, - laboratoryConfirmation: getDateFieldValue( - diseaseOutbreakEventFieldIds.laboratoryConfirmation, - allFields - ) as Date, - appropriateCaseManagement: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementNA, - allFields - ), - }, - initiatePublicHealthCounterMeasures: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresNA, - allFields - ), - }, - initiateRiskCommunication: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationNA, - allFields - ), - }, - establishCoordination: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationNa, - allFields - ), - }, - responseNarrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.responseNarrative, - allFields - ), - }, - incidentManagerName: getStringFieldValue( - diseaseOutbreakEventFieldIds.incidentManagerName, - allFields - ), - notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), - }; - - const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { - id: diseaseOutbreakEvent?.id || "", - status: diseaseOutbreakEvent?.status || "ACTIVE", - created: diseaseOutbreakEvent?.created, - lastUpdated: diseaseOutbreakEvent?.lastUpdated, - createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, - ...diseaseOutbreakEventEditableData, - }; - - return diseaseOutbreakEventBase; -} - function mapFormStateToRiskAssessmentGrading(formState: FormState): RiskAssessmentGrading { const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index a340bd27..d1fab7b1 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from "react"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; + import { Maybe } from "../../../utils/ts-utils"; import i18n from "../../../utils/i18n"; import { useAppContext } from "../../contexts/app-context"; @@ -6,7 +8,7 @@ import { Id } from "../../../domain/entities/Ref"; import { FormState, isValidForm } from "../../components/form/FormState"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { mapFormStateToEntityData } from "./mapFormStateToEntityData"; -import { updateAndValidateFormState } from "./utils/updateDiseaseOutbreakEventFormState"; +import { updateAndValidateFormState } from "./utils/updateAndValidateFormState"; import { FormFieldState } from "../../components/form/FormFieldsState"; import { FormType } from "./FormPage"; import { ConfigurableForm, FormLables } from "../../../domain/entities/ConfigurableForm"; @@ -22,10 +24,11 @@ import { } from "./incident-action/mapIncidentActionToInitialFormState"; import { useExistingEventTrackerTypes } from "../../contexts/existing-event-tracker-types-context"; import { useCheckWritePermission } from "../../hooks/useHasCurrentUserCaptureAccess"; -import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { usePerformanceOverview } from "../dashboard/usePerformanceOverview"; import { useIncidentActionPlan } from "../incident-action-plan/useIncidentActionPlan"; import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { ModalData } from "../../components/form/Form"; +import { useDiseaseOutbreakEventForm } from "./disease-outbreak-event/useDiseaseOutbreakEventForm"; export type GlobalMessage = { text: string; @@ -53,6 +56,9 @@ type State = { globalMessage: Maybe; formState: FormLoadState; isLoading: boolean; + openModal: boolean; + modalData?: ModalData; + setOpenModal: (open: boolean) => void; handleFormChange: (updatedField: FormFieldState) => void; onPrimaryButtonClick: () => void; onCancelForm: () => void; @@ -79,6 +85,16 @@ export function useForm(formType: FormType, id?: Id): State { const [isLoading, setIsLoading] = useState(false); const [formSectionsToDelete, setFormSectionsToDelete] = useState([]); const [entityData, setEntityData] = useState(); + const [openModal, setOpenModal] = useState(false); + const [modalData, setModalData] = useState(); + + const { onSaveDiseaseOutbreakEvent } = useDiseaseOutbreakEventForm({ + editMode: !!id, + setIsLoading, + setGlobalMessage, + setOpenModal, + setModalData, + }); const allDataPerformanceEvents = dataPerformanceOverview?.map( event => event.hazardType || event.suspectedDisease @@ -335,133 +351,113 @@ export function useForm(formType: FormType, id?: Id): State { ); const onPrimaryButtonClick = useCallback(() => { - const { eventTrackerConfigurations } = configurations.selectableOptions; if (formState.kind !== "loaded" || !configurableForm || !formState.data.isValid) return; - setIsLoading(true); - const formData = mapFormStateToEntityData( formState.data, currentUser.username, configurableForm ); - compositionRoot.save.execute(formData, configurations, formSectionsToDelete).run( - diseaseOutbreakEventId => { - setIsLoading(false); - - switch (formData.type) { - case "disease-outbreak-event": - if (diseaseOutbreakEventId && formData.entity) { - compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts - .execute( - diseaseOutbreakEventId, - formData.entity, - eventTrackerConfigurations.hazardTypes, - eventTrackerConfigurations.suspectedDiseases - ) - .run( - () => {}, - err => { - console.error({ err }); - } - ); - goTo(RouteName.EVENT_TRACKER, { - id: diseaseOutbreakEventId, + if ( + formData.type === "disease-outbreak-event" || + formData.type === "disease-outbreak-event-case-data" + ) { + onSaveDiseaseOutbreakEvent(formData); + } else { + setIsLoading(true); + compositionRoot.save.execute(formData, configurations, !!id, formSectionsToDelete).run( + _diseaseOutbreakEventId => { + setIsLoading(false); + switch (formData.type) { + case "risk-assessment-grading": + if (currentEventTracker?.id) + goTo(RouteName.EVENT_TRACKER, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Risk Assessment Grading saved successfully`), + type: "success", + }); + break; + case "risk-assessment-summary": + goTo(RouteName.CREATE_FORM, { + formType: "risk-assessment-questionnaire", }); setGlobalMessage({ - text: i18n.t(`Disease Outbreak saved successfully`), + text: i18n.t(`Risk Assessment Summary saved successfully`), type: "success", }); - } - break; - - case "risk-assessment-grading": - if (currentEventTracker?.id) - goTo(RouteName.EVENT_TRACKER, { - id: currentEventTracker?.id, + break; + case "risk-assessment-questionnaire": + goTo(RouteName.CREATE_FORM, { + formType: "risk-assessment-grading", }); - setGlobalMessage({ - text: i18n.t(`Risk Assessment Grading saved successfully`), - type: "success", - }); - break; - case "risk-assessment-summary": - goTo(RouteName.CREATE_FORM, { - formType: "risk-assessment-questionnaire", - }); - setGlobalMessage({ - text: i18n.t(`Risk Assessment Summary saved successfully`), - type: "success", - }); - break; - case "risk-assessment-questionnaire": - goTo(RouteName.CREATE_FORM, { - formType: "risk-assessment-grading", - }); - setGlobalMessage({ - text: i18n.t(`Risk Assessment Questionnaire saved successfully`), - type: "success", - }); - break; - case "incident-action-plan": - goTo(RouteName.CREATE_FORM, { - formType: "incident-response-actions", - }); - setGlobalMessage({ - text: i18n.t(`Incident Action Plan saved successfully`), - type: "success", - }); - break; - case "incident-response-actions": - if (currentEventTracker?.id) - goTo(RouteName.INCIDENT_ACTION_PLAN, { - id: currentEventTracker?.id, + setGlobalMessage({ + text: i18n.t(`Risk Assessment Questionnaire saved successfully`), + type: "success", }); - setGlobalMessage({ - text: i18n.t(`Incident Response Actions saved successfully`), - type: "success", - }); - break; - case "incident-response-action": - if (currentEventTracker?.id) - goTo(RouteName.INCIDENT_ACTION_PLAN, { - id: currentEventTracker?.id, + break; + case "incident-action-plan": + goTo(RouteName.CREATE_FORM, { + formType: "incident-response-actions", }); - setGlobalMessage({ - text: i18n.t(`Incident Response Actions saved successfully`), - type: "success", - }); - break; - case "incident-management-team-member-assignment": - if (currentEventTracker?.id) - goTo(RouteName.IM_TEAM_BUILDER, { - id: currentEventTracker?.id, + setGlobalMessage({ + text: i18n.t(`Incident Action Plan saved successfully`), + type: "success", }); - setGlobalMessage({ - text: i18n.t(`Incident Management Team Member saved successfully`), - type: "success", - }); - break; + break; + case "incident-response-actions": + if (currentEventTracker?.id) + goTo(RouteName.INCIDENT_ACTION_PLAN, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Response Actions saved successfully`), + type: "success", + }); + break; + case "incident-response-action": + if (currentEventTracker?.id) + goTo(RouteName.INCIDENT_ACTION_PLAN, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Response Actions saved successfully`), + type: "success", + }); + break; + case "incident-management-team-member-assignment": + if (currentEventTracker?.id) + goTo(RouteName.IM_TEAM_BUILDER, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Management Team Member saved successfully`), + type: "success", + }); + break; + } + }, + err => { + setGlobalMessage({ + text: i18n.t(`Error saving disease outbreak: ${err.message}`), + type: "error", + }); } - }, - err => { - setGlobalMessage({ - text: i18n.t(`Error saving disease outbreak: ${err.message}`), - type: "error", - }); - } - ); + ); + } }, [ - configurations, - formState, + compositionRoot.save, configurableForm, + configurations, + currentEventTracker?.id, currentUser.username, formSectionsToDelete, - compositionRoot.save, - compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts, - currentEventTracker?.id, + formState, goTo, + id, + onSaveDiseaseOutbreakEvent, ]); const onCancelForm = useCallback(() => { @@ -495,6 +491,9 @@ export function useForm(formType: FormType, id?: Id): State { globalMessage, formState, isLoading, + openModal, + modalData, + setOpenModal, handleFormChange, onPrimaryButtonClick, onCancelForm, diff --git a/src/webapp/pages/form-page/utils/FileHelper.ts b/src/webapp/pages/form-page/utils/FileHelper.ts new file mode 100644 index 00000000..33237785 --- /dev/null +++ b/src/webapp/pages/form-page/utils/FileHelper.ts @@ -0,0 +1,40 @@ +import * as XLSX from "xlsx"; + +import { Row, SheetData } from "../../../components/form/FormFieldsState"; + +export async function readFile(file: File): Promise { + const workbook = XLSX.read(await file.arrayBuffer(), { cellDates: true }); + + return Object.entries(workbook.Sheets).map(([sheetName, worksheet]): SheetData => { + const headers = + XLSX.utils.sheet_to_json(worksheet, { + header: 1, + defval: "", + })[0] || []; + const rows = XLSX.utils.sheet_to_json(worksheet, { + raw: true, + skipHidden: false, + }); + + return { + name: sheetName, + headers: headers, + rows: rows, + }; + }); +} + +export function doesColumnExist(header: string[], column: string): boolean { + return header.find(value => value === column) !== undefined; +} + +export function formatDateToDateString(date: Date | string): string | undefined { + if (date instanceof Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } else { + return date; + } +} diff --git a/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts b/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts similarity index 89% rename from src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts rename to src/webapp/pages/form-page/utils/updateAndValidateFormState.ts index c098c297..8cc356c1 100644 --- a/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts +++ b/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts @@ -12,6 +12,7 @@ import { updateFormStateWithFieldErrors, validateForm, } from "../../../components/form/FormState"; +import { validateCaseSheetData } from "../disease-outbreak-event/CaseDataFileFieldHelper"; import { applyRulesInFormState } from "./applyRulesInFormState"; export function updateAndValidateFormState( @@ -53,11 +54,16 @@ function validateFormState( ): ValidationError[] { const formValidationErrors = validateForm(updatedForm, updatedField); let entityValidationErrors: ValidationError[] = []; + let sheetDataValidationErrors: ValidationError[] = []; switch (configurableForm.type) { case "disease-outbreak-event": { if (configurableForm.entity) entityValidationErrors = DiseaseOutbreakEvent.validate(configurableForm.entity); + sheetDataValidationErrors = + updatedField.type === "file" + ? [validateCaseSheetData(updatedField, configurableForm.orgUnits)] + : []; break; } case "risk-assessment-grading": @@ -90,5 +96,5 @@ function validateFormState( } } - return [...formValidationErrors, ...entityValidationErrors]; + return [...formValidationErrors, ...entityValidationErrors, ...sheetDataValidationErrors]; } diff --git a/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx b/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx index f1d6b9e0..5418cc3b 100644 --- a/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx +++ b/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx @@ -40,7 +40,7 @@ export const ResponseActionTable: React.FC = React.mem
= React.memo(props => {
{
{selectedHierarchyItemIds.length > 1 ? null : (