diff --git a/i18n/en.pot b/i18n/en.pot index 25800bf9..59f8784d 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-04T07:57:22.036Z\n" -"PO-Revision-Date: 2024-09-04T07:57:22.036Z\n" +"POT-Creation-Date: 2024-09-30T07:39:32.843Z\n" +"PO-Revision-Date: 2024-09-30T07:39:32.843Z\n" msgid "Low" msgstr "" @@ -63,6 +63,12 @@ msgstr "" msgid "Add new option" msgstr "" +msgid "Reset" +msgstr "" + +msgid "Save" +msgstr "" + msgid "There is an error in this field" msgstr "" @@ -72,10 +78,10 @@ msgstr "" msgid "Cancel" msgstr "" -msgid "Save" +msgid "Edit Details" msgstr "" -msgid "Edit Details" +msgid "Notes" msgstr "" msgid "Create Event" @@ -105,6 +111,12 @@ msgstr "" msgid "Respond, alert, watch" msgstr "" +msgid "Duration" +msgstr "" + +msgid "7-1-7 performance" +msgstr "" + msgid "Performance overview" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 4700fbfc..b9bcc431 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-04T07:57:22.036Z\n" +"POT-Creation-Date: 2024-09-12T14:10:04.460Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -62,6 +62,12 @@ msgstr "" msgid "Add new option" msgstr "" +msgid "Reset" +msgstr "" + +msgid "Save" +msgstr "" + msgid "There is an error in this field" msgstr "" diff --git a/package.json b/package.json index 73c0a08f..32c84ab4 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@dhis2/ui": "6.12.0", "@emotion/react": "11.11.4", "@emotion/styled": "11.11.5", - "@eyeseetea/d2-api": "1.16.0-beta.9", + "@eyeseetea/d2-api": "1.16.0-beta.12", "@eyeseetea/d2-ui-components": "v2.9.0-beta.2", "@eyeseetea/feedback-component": "0.0.3", "@material-ui/core": "4.12.4", @@ -31,6 +31,7 @@ "d2-manifest": "1.0.0", "dotenv": "^16.4.5", "font-awesome": "4.7.0", + "moment": "^2.30.1", "purify-ts": "1.2.0", "purify-ts-extra-codec": "0.6.0", "react": "^18.2.0", diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index f4f2390b..ff0d5bdc 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -20,12 +20,18 @@ import { OrgUnitTestRepository } from "./data/repositories/test/OrgUnitTestRepos import { GetAllDiseaseOutbreaksUseCase } from "./domain/usecases/GetAllDiseaseOutbreaksUseCase"; import { SaveDiseaseOutbreakUseCase } from "./domain/usecases/SaveDiseaseOutbreakUseCase"; import { GetDiseaseOutbreakWithOptionsUseCase } from "./domain/usecases/GetDiseaseOutbreakWithOptionsUseCase"; +import { PerformanceOverviewRepository } from "./domain/repositories/PerformanceOverviewRepository"; +import { GetAllPerformanceOverviewMetricsUseCase } from "./domain/usecases/GetAllPerformanceOverviewMetricsUseCase"; +import { PerformanceOverviewD2Repository } from "./data/repositories/PerformanceOverviewD2Repository"; +import { PerformanceOverviewTestRepository } from "./data/repositories/test/PerformanceOverviewTestRepository"; +import { GetTotalCardCountsUseCase } from "./domain/usecases/GetDiseasesTotalUseCase"; import { MapDiseaseOutbreakToAlertsUseCase } from "./domain/usecases/MapDiseaseOutbreakToAlertsUseCase"; import { AlertRepository } from "./domain/repositories/AlertRepository"; import { AlertTestRepository } from "./data/repositories/test/AlertTestRepository"; import { AlertSyncDataStoreRepository } from "./data/repositories/AlertSyncDataStoreRepository"; import { AlertSyncDataStoreTestRepository } from "./data/repositories/test/AlertSyncDataStoreTestRepository"; import { AlertSyncRepository } from "./domain/repositories/AlertSyncRepository"; +import { DataStoreClient } from "./data/DataStoreClient"; export type CompositionRoot = ReturnType; @@ -37,6 +43,7 @@ type Repositories = { optionsRepository: OptionsRepository; teamMemberRepository: TeamMemberRepository; orgUnitRepository: OrgUnitRepository; + performanceOverviewRepository: PerformanceOverviewRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -55,10 +62,19 @@ function getCompositionRoot(repositories: Repositories) { repositories.optionsRepository ), }, + performanceOverview: { + getPerformanceOverviewMetrics: new GetAllPerformanceOverviewMetricsUseCase( + repositories + ), + getTotalCardCounts: new GetTotalCardCountsUseCase( + repositories.performanceOverviewRepository + ), + }, }; } export function getWebappCompositionRoot(api: D2Api) { + const dataStoreClient = new DataStoreClient(api); const repositories: Repositories = { usersRepository: new UserD2Repository(api), diseaseOutbreakEventRepository: new DiseaseOutbreakEventD2Repository(api), @@ -67,6 +83,7 @@ export function getWebappCompositionRoot(api: D2Api) { optionsRepository: new OptionsD2Repository(api), teamMemberRepository: new TeamMemberD2Repository(api), orgUnitRepository: new OrgUnitD2Repository(api), + performanceOverviewRepository: new PerformanceOverviewD2Repository(api, dataStoreClient), }; return getCompositionRoot(repositories); @@ -81,6 +98,7 @@ export function getTestCompositionRoot() { optionsRepository: new OptionsTestRepository(), teamMemberRepository: new TeamMemberTestRepository(), orgUnitRepository: new OrgUnitTestRepository(), + performanceOverviewRepository: new PerformanceOverviewTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index ebb477d8..d0056f75 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -36,9 +36,11 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep return Future.fromPromise( getAllTrackedEntitiesAsync(this.api, RTSL_ZEBRA_PROGRAM_ID, RTSL_ZEBRA_ORG_UNIT_ID) ).map(trackedEntities => { - return trackedEntities.map(trackedEntity => { - return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); - }); + return trackedEntities + .map(trackedEntity => { + return mapTrackedEntityAttributesToDiseaseOutbreak(trackedEntity); + }) + .filter(outbreak => outbreak.status === "ACTIVE"); }); } diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts new file mode 100644 index 00000000..71d434a1 --- /dev/null +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -0,0 +1,225 @@ +import { Maybe } from "../../utils/ts-utils"; +import { AnalyticsResponse, D2Api } from "../../types/d2-api"; +import { PerformanceOverviewRepository } from "../../domain/repositories/PerformanceOverviewRepository"; +import { apiToFuture, FutureData } from "../api-futures"; +import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; +import _ from "../../domain/entities/generic/Collection"; +import { Future } from "../../domain/entities/generic/Future"; +import { evenTrackerCountsIndicatorMap, IndicatorsId } from "./consts/PerformanceOverviewConstants"; +import moment from "moment"; +import { + DiseaseOutbreakEventBaseAttrs, + NationalIncidentStatus, +} from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { DataStoreClient } from "../DataStoreClient"; +import { + TotalCardCounts, + HazardNames, + PerformanceOverviewMetrics, + DiseaseNames, +} from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; +import { OrgUnit } from "../../domain/entities/OrgUnit"; + +export class PerformanceOverviewD2Repository implements PerformanceOverviewRepository { + constructor(private api: D2Api, private datastore: DataStoreClient) {} + + getTotalCardCounts(filters?: Record): FutureData { + return apiToFuture( + this.api.analytics.get({ + dimension: [ + `dx:${evenTrackerCountsIndicatorMap.map(({ id }) => id).join(";")}`, + "ou:LEVEL-2", + "pe:THIS_YEAR", + ], + }) + ).map(analyticsResponse => { + const totalCardCounts = + this.mapAnalyticsRowsToTotalCardCounts(analyticsResponse.rows, filters) || []; + + const uniqueTotalCardCounts = totalCardCounts.reduce((acc, totalCardCount) => { + const existingEntry = acc[totalCardCount.name]; + + if (existingEntry) { + existingEntry.total += totalCardCount.total; + acc[totalCardCount.name] = existingEntry; + } else { + acc[totalCardCount.name] = totalCardCount; + } + return acc; + }, {} as Record); + + return Object.values(uniqueTotalCardCounts); + }); + } + mapAnalyticsRowsToTotalCardCounts = ( + rowData: string[][], + filters?: Record + ): TotalCardCounts[] => { + const counts: TotalCardCounts[] = _( + rowData.map(([id, _orgUnit, _period, total]) => { + const indicator = evenTrackerCountsIndicatorMap.find(d => d.id === id); + if (!indicator || !total) { + return null; + } + + if (indicator.type === "hazard") { + const hazardCount = { + id: id, + name: indicator.name, + type: indicator.type, + incidentStatus: indicator.incidentStatus, + total: parseFloat(total), + }; + return hazardCount; + } else { + const diseaseCount = { + id: id, + name: indicator.name, + type: indicator.type, + incidentStatus: indicator.incidentStatus, + total: parseFloat(total), + }; + return diseaseCount; + } + }) + ) + .compact() + .value(); + + const filteredCounts: TotalCardCounts[] = counts.filter(item => { + if (filters && Object.entries(filters).length) { + return Object.entries(filters).every(([key, values]) => { + if (!values.length) { + return true; + } + if (key === "incidentStatus") { + return values.includes(item.incidentStatus as string); + } else if (key === "disease" || key === "hazard") { + return values.includes(item.name as string); + } + }); + } + return true; + }); + return filteredCounts; + }; + + getPerformanceOverviewMetrics( + diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] + ): FutureData { + return apiToFuture( + this.api.get( + `/analytics/enrollments/query/${RTSL_ZEBRA_PROGRAM_ID}`, + { + enrollmentDate: "LAST_12_MONTHS,THIS_MONTH", + dimension: [ + IndicatorsId.suspectedDisease, + IndicatorsId.hazardType, + IndicatorsId.event, + IndicatorsId.era1, + IndicatorsId.era2, + IndicatorsId.era3, + IndicatorsId.era4, + IndicatorsId.era5, + IndicatorsId.era6, + IndicatorsId.era7, + IndicatorsId.detect7d, + IndicatorsId.notify1d, + IndicatorsId.respond7d, + ], + } + ) + ).flatMap(indicatorsProgramFuture => { + const mappedIndicators = + indicatorsProgramFuture?.rows.map((row: string[]) => + this.mapRowToBaseIndicator( + row, + indicatorsProgramFuture.headers, + indicatorsProgramFuture.metaData + ) + ) || []; + + const performanceOverviewMetrics = diseaseOutbreakEvents.map(event => { + const baseIndicator = mappedIndicators.find(indicator => indicator.id === event.id); + const key = baseIndicator?.suspectedDisease || baseIndicator?.hazardType; + + return this.getCasesAndDeathsFromDatastore(key).map(casesAndDeaths => { + const duration = `${moment() + .diff(moment(event.emerged.date), "days") + .toString()}d`; + if (!baseIndicator) { + return { + id: event.id, + event: event.name, + manager: event.incidentManagerName, + duration: duration, + nationalIncidentStatus: event.incidentStatus, + cases: casesAndDeaths.cases.toString(), + deaths: casesAndDeaths.deaths.toString(), + } as PerformanceOverviewMetrics; + } + return { + ...baseIndicator, + nationalIncidentStatus: event.incidentStatus, + manager: event.incidentManagerName, + duration: duration, + cases: casesAndDeaths.cases.toString(), + deaths: casesAndDeaths.deaths.toString(), + } as PerformanceOverviewMetrics; + }); + }); + + return Future.sequential(performanceOverviewMetrics); + }); + } + + private getCasesAndDeathsFromDatastore( + key: string | undefined + ): FutureData<{ cases: number; deaths: number }> { + if (!key) return Future.success({ cases: 0, deaths: 0 }); + return this.datastore.getObject(key).flatMap(data => { + if (!data) return Future.success({ cases: 0, deaths: 0 }); + const casesDeaths = data.alerts.reduce( + (acc, alert) => { + acc.cases += parseInt(alert.suspectedCases) || 0; + acc.deaths += parseInt(alert.deaths) || 0; + return acc; + }, + { cases: 0, deaths: 0 } + ); + + return Future.success(casesDeaths); + }); + } + + private mapRowToBaseIndicator( + row: string[], + headers: { name: string; column: string }[], + metaData: AnalyticsResponse["metaData"] + ): Partial { + return headers.reduce((acc, header, index) => { + const key = Object.keys(IndicatorsId).find( + key => IndicatorsId[key as keyof typeof IndicatorsId] === header.name + ) as Maybe; + + if (!key) return acc; + + if (key === "suspectedDisease") { + acc[key] = + (Object.values(metaData.items).find(item => item.code === row[index]) + ?.name as DiseaseNames) || ""; + } else if (key === "hazardType") { + acc[key] = + (Object.values(metaData.items).find(item => item.code === row[index]) + ?.name as HazardNames) || ""; + } else if (key === "nationalIncidentStatus") { + acc[key] = row[index] as NationalIncidentStatus; + } else { + acc[key] = row[index] as (HazardNames & OrgUnit[]) | undefined; + } + + return acc; + }, {} as Partial); + } +} diff --git a/src/data/repositories/TeamMemberD2Repository.ts b/src/data/repositories/TeamMemberD2Repository.ts index 3643670a..acc5f898 100644 --- a/src/data/repositories/TeamMemberD2Repository.ts +++ b/src/data/repositories/TeamMemberD2Repository.ts @@ -6,6 +6,8 @@ import { apiToFuture, FutureData } from "../api-futures"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; +const RTSL_ZEBRA_INCIDENTMANAGER = "UOd3K79040G"; + export class TeamMemberD2Repository implements TeamMemberRepository { constructor(private api: D2Api) {} @@ -26,6 +28,24 @@ export class TeamMemberD2Repository implements TeamMemberRepository { ); }); } + getIncidentManagers(): FutureData { + return apiToFuture( + this.api.metadata.get({ + users: { + fields: d2UserFields, + filter: { "userGroups.id": { in: [RTSL_ZEBRA_INCIDENTMANAGER] } }, + }, + }) + ) + .flatMap(response => assertOrError(response.users, `Team Members not found`)) + .flatMap(d2Users => { + if (d2Users.length === 0) return Future.error(new Error(`Team Members not found`)); + else + return Future.success( + d2Users.map(d2User => this.mapUserToTeamMember(d2User as D2UserFix)) + ); + }); + } get(id: Id): FutureData { return apiToFuture( diff --git a/src/data/repositories/consts/DiseaseOutbreakConstants.ts b/src/data/repositories/consts/DiseaseOutbreakConstants.ts index b5afac16..09db25a6 100644 --- a/src/data/repositories/consts/DiseaseOutbreakConstants.ts +++ b/src/data/repositories/consts/DiseaseOutbreakConstants.ts @@ -2,8 +2,9 @@ import { DataSource, DiseaseOutbreakEventBaseAttrs, HazardType, - IncidentStatus, + NationalIncidentStatus, } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import _c from "../../../domain/entities/generic/Collection"; import { GetValue, Maybe } from "../../../utils/ts-utils"; import { getDateAsIsoString } from "../utils/DateTimeHelper"; @@ -26,12 +27,13 @@ export const hazardTypeCodeMap: Record = { Unknown: "RTSL_ZEB_OS_HAZARD_TYPE_UNKNOWN", }; -export const incidentStatusMap: Record = { - RTSL_ZEB_OS_INCIDENT_STATUS_WATCH: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, - RTSL_ZEB_OS_INCIDENT_STATUS_ALERT: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_ALERT, - RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND, - RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED, - RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED, +export const incidentStatusMap: Record = { + RTSL_ZEB_OS_INCIDENT_STATUS_WATCH: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + RTSL_ZEB_OS_INCIDENT_STATUS_ALERT: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_ALERT, + RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND, + RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED, + RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED: + NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED, }; export const dataSourceMap: Record = { @@ -65,6 +67,7 @@ export const diseaseOutbreakCodes = { initiatePublicHealthCounterMeasuresDate: "RTSL_ZEB_TEA_SPECIFY_DATE3", initiateRiskCommunicationNA: "RTSL_ZEB_TEA_APPROPRIATE_RISK_COMMUNICATION_NA", initiateRiskCommunicationDate: "RTSL_ZEB_TEA_SPECIFY_DATE4", + earliestRespondDate: "RTSL_ZEB_TEA_EARLIEST_RESPOND_DATE", establishCoordination: "RTSL_ZEB_TEA_ESTABLISH_COORDINATION_MECHANISM", responseNarrative: "RTSL_ZEB_TEA_RESPONSE_NARRATIVE", incidentManager: "RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER", @@ -83,6 +86,20 @@ export function isStringInDiseaseOutbreakCodes(code: string): code is KeyCode { export function getValueFromDiseaseOutbreak( diseaseOutbreak: DiseaseOutbreakEventBaseAttrs ): Record { + //Set Earliest Respond Date as the earliest of all early response action dates. + const responseActionDates: number[] = _c([ + diseaseOutbreak.earlyResponseActions.appropriateCaseManagement.date?.getTime(), + diseaseOutbreak.earlyResponseActions.conductEpidemiologicalAnalysis.getTime(), + diseaseOutbreak.earlyResponseActions.initiateInvestigation.getTime(), + diseaseOutbreak.earlyResponseActions.establishCoordination.getTime(), + diseaseOutbreak.earlyResponseActions.initiateRiskCommunication.date?.getTime(), + diseaseOutbreak.earlyResponseActions.initiatePublicHealthCounterMeasures.date?.getTime(), + diseaseOutbreak.earlyResponseActions.laboratoryConfirmation.date?.getTime(), + ]) + .compact() + .value(); + + const earliestRespondDate: Date = new Date(Math.min(...responseActionDates)); return { RTSL_ZEB_TEA_EVENT_NAME: diseaseOutbreak.name, RTSL_ZEB_TEA_DATA_SOURCE: diseaseOutbreak.dataSource, @@ -140,6 +157,7 @@ export function getValueFromDiseaseOutbreak( RTSL_ZEB_TEA_ESTABLISH_COORDINATION_MECHANISM: getDateAsIsoString( diseaseOutbreak.earlyResponseActions.establishCoordination ), + RTSL_ZEB_TEA_EARLIEST_RESPOND_DATE: getDateAsIsoString(earliestRespondDate), RTSL_ZEB_TEA_RESPONSE_NARRATIVE: diseaseOutbreak.earlyResponseActions.responseNarrative, RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER: diseaseOutbreak.incidentManagerName, RTSL_ZEB_TEA_NOTES: diseaseOutbreak.notes ?? "", diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts new file mode 100644 index 00000000..5aaa9606 --- /dev/null +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -0,0 +1,251 @@ +import { + DiseaseNames, + HazardNames, + IncidentStatus, +} from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { Id } from "../../../domain/entities/Ref"; + +export enum IndicatorsId { + suspectedDisease = "jLvbkuvPdZ6", + hazardType = "Dzrw3Tf0ukB", + event = "fyrLOW9Iwwv", + era1 = "Ylmo2fEijff", + era2 = "w4FOvRAyjEE", + era3 = "RdLmpMM7lM5", + era4 = "xT4TgUZhMkk", + era5 = "UwEdN0kWFqv", + era6 = "xtetmvZ9WoV", + era7 = "GgUJMCklxFu", + detect7d = "cGFwM7qiPzl", + notify1d = "HDa3nE7Elxj", + respond7d = "yxVOW4lj4xP", + province = "ouname", + creationDate = "lastupdated", + id = "tei", + nationalIncidentStatus = "incidentStatus", +} + +export const NB_OF_CASES = [ + { + id: "fTDKNLsnjIV", + disease: "AFP", + }, + { + id: "VkIaxVgudJ6", + disease: "Acute VHF", + }, + { + id: "WhNO2qLViUr", + disease: "Acute respiratory", + }, + { + id: "zRD7B2SCtTL", + disease: "Anthrax", + }, + { + id: "xJCArVoiVv7", + disease: "Bacterial meningitis", + }, + { + id: "BuSwWZ7LS0M", + disease: "COVID19", + }, + { + id: "J44EMa8ARVJ", + disease: "Cholera", + }, + { + id: "W1zvn77txyE", + disease: "Diarrhoea with blood", + }, + { + id: "jYq8uL2Rly5", + disease: "Measles", + }, + { + id: "oRmeFNBsNd1", + disease: "Monkeypox", + }, + { + id: "UFbNrAk6CfZ", + disease: "Neonatal tetanus", + }, + { + id: "WaQ1KeTe5jd", + disease: "Plague", + }, + { + id: "f9scFbMvvDx", + disease: "SARIs", + }, + { + id: "W8j7yMGG7qD", + disease: "Typhoid fever", + }, + { + id: "yD6Rl5hHMg5", + disease: "Zika fever", + }, + { + id: "aYztCKYUy3o", + disease: "Animal type", + }, + { + id: "iJhV5JhqUh3", + disease: "Human type", + }, + { + id: "NQCfq7qVNqD", + disease: "Human and Animal type", + }, + + { + id: "KTPFFaddRMq", + disease: "Environmental type", + }, +]; + +export const NB_OF_DEATHS = [ + { + id: "Uic7GHCJ2OS", + disease: "AFP", + }, + { + id: "OVtL3yPhMPG", + disease: "Acute VHF", + }, + { + id: "yMDKmk204qU", + disease: "Acute respiratory", + }, + { + id: "folsJlzBpS9", + disease: "Anthrax", + }, + { + id: "ACi2Kn3rrWd", + disease: "Bacterial meningitis", + }, + { + id: "P4RZ0W8giNn", + disease: "COVID19", + }, + { + id: "wPjA4MQGkbq", + disease: "Cholera", + }, + { + id: "C3v4bxNQk5g", + disease: "Diarrhoea with blood", + }, + { + id: "gcy8tqeKuIR", + disease: "Measles", + }, + { + id: "DYsrZEDNFzy", + disease: "Monkeypox", + }, + { + id: "hWU7I30NN6V", + disease: "Neonatal tetanus", + }, + { + id: "knj5Ahdrwuc", + disease: "Plague", + }, + { + id: "mklIyGZLCHT", + disease: "SARIs", + }, + { + id: "UfXGJVsgn2B", + disease: "Typhoid fever", + }, + { + id: "h70TBX2YxMB", + disease: "Zika fever", + }, +]; + +type EventTrackerCountIndicatorBase = { + id: Id; + type: "disease" | "hazard"; + name: DiseaseNames | HazardNames; + incidentStatus: IncidentStatus; + count?: number; +}; + +export type EventTrackerCountDiseaseIndicator = EventTrackerCountIndicatorBase & { + type: "disease"; + name: DiseaseNames; +}; + +export type EventTrackerCountHazardIndicator = EventTrackerCountIndicatorBase & { + type: "hazard"; + name: HazardNames; +}; + +export type EventTrackerCountIndicator = + | EventTrackerCountDiseaseIndicator + | EventTrackerCountHazardIndicator; + +export const evenTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ + { id: "SGGbbu0AKUv", type: "disease", name: "Acute respiratory", incidentStatus: "Watch" }, + { id: "QnhsQnEsp1p", type: "disease", name: "Acute respiratory", incidentStatus: "Alert" }, + { id: "Rt5KNVqBEO7", type: "disease", name: "Acute respiratory", incidentStatus: "Respond" }, + { id: "bcI9Rmx2ycH", type: "disease", name: "Acute VHF", incidentStatus: "Watch" }, + { id: "u4XTtjm9nEh", type: "disease", name: "Acute VHF", incidentStatus: "Alert" }, + { id: "gpKelVBHhRZ", type: "disease", name: "Acute VHF", incidentStatus: "Respond" }, + { id: "pqob28cwd3i", type: "disease", name: "AFP", incidentStatus: "Watch" }, + { id: "PHhaZK4KeOA", type: "disease", name: "AFP", incidentStatus: "Alert" }, + { id: "SyemUCen8zf", type: "disease", name: "AFP", incidentStatus: "Respond" }, + { id: "YPPhLHgwiKV", type: "disease", name: "Anthrax", incidentStatus: "Watch" }, + { id: "FhdaufdE8l3", type: "disease", name: "Anthrax", incidentStatus: "Alert" }, + { id: "vuhm2b5D076", type: "disease", name: "Anthrax", incidentStatus: "Respond" }, + { id: "qeQSDdPTeVq", type: "disease", name: "Bacterial meningitis", incidentStatus: "Watch" }, + { id: "WXlyJHUKI8T", type: "disease", name: "Bacterial meningitis", incidentStatus: "Alert" }, + { id: "DCwOujun1ED", type: "disease", name: "Bacterial meningitis", incidentStatus: "Respond" }, + { id: "zNctWJj7Ncl", type: "disease", name: "Cholera", incidentStatus: "Watch" }, + { id: "U31oe2BwJtt", type: "disease", name: "Cholera", incidentStatus: "Alert" }, + { id: "WCrE9mP80q4", type: "disease", name: "Cholera", incidentStatus: "Respond" }, + { id: "m2LBISybVDA", type: "disease", name: "COVID19", incidentStatus: "Watch" }, + { id: "sY5lGlHpcuN", type: "disease", name: "COVID19", incidentStatus: "Alert" }, + { id: "LQ128PeTF8x", type: "disease", name: "COVID19", incidentStatus: "Respond" }, + { id: "oKSsu6q3MJW", type: "disease", name: "Diarrhoea with blood", incidentStatus: "Watch" }, + { id: "EgGc7XxZjmC", type: "disease", name: "Diarrhoea with blood", incidentStatus: "Alert" }, + { id: "uAMXUxp3XBa", type: "disease", name: "Diarrhoea with blood", incidentStatus: "Respond" }, + { id: "yesuR8ho9vY", type: "disease", name: "Measles", incidentStatus: "Watch" }, + { id: "OvxA9yqaH7q", type: "disease", name: "Measles", incidentStatus: "Alert" }, + { id: "q9HlUfaQj3p", type: "disease", name: "Measles", incidentStatus: "Respond" }, + { id: "mw7Qxti6Fk5", type: "disease", name: "Monkeypox", incidentStatus: "Watch" }, + { id: "kMsSxdZMqJV", type: "disease", name: "Monkeypox", incidentStatus: "Alert" }, + { id: "qL6WGfcoh1l", type: "disease", name: "Monkeypox", incidentStatus: "Respond" }, + { id: "eo2RAoIRYiV", type: "disease", name: "Neonatal tetanus", incidentStatus: "Watch" }, + { id: "EuIc8gJYAhP", type: "disease", name: "Neonatal tetanus", incidentStatus: "Alert" }, + { id: "H7Fmb58GUF9", type: "disease", name: "Neonatal tetanus", incidentStatus: "Respond" }, + { id: "IYktWOGBTtj", type: "disease", name: "Plague", incidentStatus: "Watch" }, + { id: "qdLWFsb7Ghk", type: "disease", name: "Plague", incidentStatus: "Alert" }, + { id: "nbG4Lnl1JUz", type: "disease", name: "Plague", incidentStatus: "Respond" }, + { id: "fEdwx7X6BLI", type: "disease", name: "SARIs", incidentStatus: "Watch" }, + { id: "FSstKrL8oys", type: "disease", name: "SARIs", incidentStatus: "Alert" }, + { id: "SkkAznpVZzr", type: "disease", name: "SARIs", incidentStatus: "Respond" }, + { id: "JcfEcfD64Gy", type: "disease", name: "Typhoid fever", incidentStatus: "Watch" }, + { id: "wfsBvSq7Hn1", type: "disease", name: "Typhoid fever", incidentStatus: "Alert" }, + { id: "FMKLwKkOUzx", type: "disease", name: "Typhoid fever", incidentStatus: "Respond" }, + { id: "XieBgoffFRd", type: "disease", name: "Zika fever", incidentStatus: "Watch" }, + { id: "tIYANWCiMoR", type: "disease", name: "Zika fever", incidentStatus: "Alert" }, + { id: "qJjRR8EwYgB", type: "disease", name: "Zika fever", incidentStatus: "Respond" }, + { id: "gMoRiHe1Z0Z", type: "hazard", name: "Animal type", incidentStatus: "Watch" }, + { id: "tKLdMcWUg9l", type: "hazard", name: "Animal type", incidentStatus: "Alert" }, + { id: "TJhGnX8E7CP", type: "hazard", name: "Animal type", incidentStatus: "Respond" }, + { id: "YfkOUZPhCY1", type: "hazard", name: "Human type", incidentStatus: "Watch" }, + { id: "NzpH7Y76JBw", type: "hazard", name: "Human type", incidentStatus: "Alert" }, + { id: "jWDbWYr85DP", type: "hazard", name: "Human type", incidentStatus: "Respond" }, + { id: "kLtsjiyIzer", type: "hazard", name: "Human and Animal type", incidentStatus: "Watch" }, + { id: "ge4Jwq2MGrF", type: "hazard", name: "Human and Animal type", incidentStatus: "Alert" }, + { id: "GQ6Yg9ZN4xL", type: "hazard", name: "Human and Animal type", incidentStatus: "Respond" }, + { id: "Bu4bafAjFXN", type: "hazard", name: "Environmental type", incidentStatus: "Watch" }, + { id: "z3EbI98pgjG", type: "hazard", name: "Environmental type", incidentStatus: "Alert" }, + { id: "gRcZNqpKyYg", type: "hazard", name: "Environmental type", incidentStatus: "Respond" }, +]; diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index aa12ba4a..821e03ca 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -2,7 +2,7 @@ import { DataSource, DiseaseOutbreakEvent, DiseaseOutbreakEventBaseAttrs, - IncidentStatus, + NationalIncidentStatus, } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../domain/entities/generic/Future"; import { Id, ConfigLabel } from "../../../domain/entities/Ref"; @@ -13,6 +13,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR get(id: Id): FutureData { return Future.success({ id: id, + status: "ACTIVE", name: "Disease Outbreak 1", dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, created: new Date(), @@ -24,7 +25,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "1", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, @@ -46,6 +47,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR return Future.success([ { id: "1", + status: "ACTIVE", name: "Disease Outbreak 1", dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, created: new Date(), @@ -57,7 +59,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "1", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, @@ -76,6 +78,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, { id: "2", + status: "ACTIVE", name: "Disease Outbreak 2", dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, created: new Date(), @@ -87,7 +90,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR notificationSourceCode: "2", areasAffectedDistrictIds: [], areasAffectedProvinceIds: [], - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, emerged: { date: new Date(), narrative: "emerged" }, detected: { date: new Date(), narrative: "detected" }, notified: { date: new Date(), narrative: "notified" }, diff --git a/src/data/repositories/test/PerformanceOverviewTestRepository.ts b/src/data/repositories/test/PerformanceOverviewTestRepository.ts new file mode 100644 index 00000000..4bbc4a4f --- /dev/null +++ b/src/data/repositories/test/PerformanceOverviewTestRepository.ts @@ -0,0 +1,115 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { PerformanceOverviewRepository } from "../../../domain/repositories/PerformanceOverviewRepository"; +import { FutureData } from "../../api-futures"; + +export class PerformanceOverviewTestRepository implements PerformanceOverviewRepository { + getTotalCardCounts(): FutureData { + return Future.success(0); + } + getPerformanceOverviewMetrics(): FutureData { + return Future.success([ + { + id: "JPenxAnjdhY", + manager: "user, dev (dev.user)", + creationDate: "2024-08-27 11:07:48.68", + province: "zm Zambia Ministry of Health", + suspectedDisease: "Cholera", + event: "test event", + era1: "", + era2: "", + era3: "", + era4: "", + era5: "", + era6: "", + era7: "", + detect7d: "", + notify1d: "", + }, + { + id: "oHSPSlkb7J5", + manager: "user, dev (dev.user)", + creationDate: "2024-08-26 17:03:17.532", + province: "zm Zambia Ministry of Health", + suspectedDisease: "Acute respiratory", + event: "test event", + era1: "", + era2: "", + era3: "", + era4: "", + era5: "", + era6: "", + era7: "", + detect7d: "", + notify1d: "", + }, + { + id: "g5C6Veut61t", + manager: "user, dev (dev.user)", + creationDate: "2024-08-27 11:06:36.869", + province: "zm Zambia Ministry of Health", + suspectedDisease: "Acute VHF", + event: "test event", + era1: "", + era2: "", + era3: "", + era4: "", + era5: "", + era6: "", + era7: "", + detect7d: "", + notify1d: "", + }, + { + id: "EtoUrZCn8mP", + manager: "user, dev (dev.user)", + creationDate: "2024-08-22 15:12:06.016", + province: "zm Zambia Ministry of Health", + suspectedDisease: "COVID19", + event: "Cholera Aug 2024", + era1: "15", + era2: "14", + era3: "", + era4: "14", + era5: "", + era6: "", + era7: "14", + detect7d: "2", + notify1d: "1", + }, + { + id: "HNiwOkH3vdJ", + manager: "user, dev (dev.user)", + creationDate: "2024-08-22 13:29:27.734", + province: "zm Zambia Ministry of Health", + suspectedDisease: "Anthrax", + event: "Anthrax July 2024", + era1: "29", + era2: "28", + era3: "", + era4: "", + era5: "", + era6: "", + era7: "17", + detect7d: "10", + notify1d: "21", + }, + { + id: "qrezSaY5G0U", + manager: "user, dev (dev.user)", + creationDate: "2024-08-22 13:25:24.505", + province: "zm Zambia Ministry of Health", + suspectedDisease: "Measles", + event: "Measles June 2024", + era1: "63", + era2: "55", + era3: "", + era4: "", + era5: "", + era6: "", + era7: "53", + detect7d: "3", + notify1d: "2", + }, + ]); + } +} diff --git a/src/data/repositories/test/TeamMemberTestRepository.ts b/src/data/repositories/test/TeamMemberTestRepository.ts index 18ea3f37..cfd50ce1 100644 --- a/src/data/repositories/test/TeamMemberTestRepository.ts +++ b/src/data/repositories/test/TeamMemberTestRepository.ts @@ -5,6 +5,20 @@ import { TeamMemberRepository } from "../../../domain/repositories/TeamMemberRep import { FutureData } from "../../api-futures"; export class TeamMemberTestRepository implements TeamMemberRepository { + getIncidentManagers(): FutureData { + const teamMember: TeamMember = new TeamMember({ + id: "incidentManager", + username: "incidentManager", + name: `Team Member Name test`, + email: `email@email.com`, + phone: `121-1234`, + role: { id: "1", name: "role" }, + status: "Available", + photo: new URL("https://www.example.com"), + }); + + return Future.success([teamMember]); + } getAll(): FutureData { const teamMember: TeamMember = new TeamMember({ id: "test", diff --git a/src/data/repositories/utils/DateTimeHelper.ts b/src/data/repositories/utils/DateTimeHelper.ts index b7501bc6..a6aff3b4 100644 --- a/src/data/repositories/utils/DateTimeHelper.ts +++ b/src/data/repositories/utils/DateTimeHelper.ts @@ -15,7 +15,11 @@ export function getDateAsIsoString(date: Maybe): string { export function getDateAsMonthYearString(date: Date): string { try { - return date.toLocaleString("default", { month: "long", year: "numeric" }); + return date.toLocaleString("default", { + day: "numeric", + month: "long", + year: "numeric", + }); } catch (e) { console.debug(e); return ""; diff --git a/src/data/repositories/utils/DiseaseOutbreakMapper.ts b/src/data/repositories/utils/DiseaseOutbreakMapper.ts index 7766c4b9..bf99d5d1 100644 --- a/src/data/repositories/utils/DiseaseOutbreakMapper.ts +++ b/src/data/repositories/utils/DiseaseOutbreakMapper.ts @@ -44,6 +44,7 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( const diseaseOutbreak: DiseaseOutbreakEventBaseAttrs = { id: trackedEntity.trackedEntity, + status: trackedEntity.enrollments?.[0]?.status ?? "ACTIVE", //Zebra Outbreak has only one enrollment name: fromMap("name"), dataSource: dataSource, created: trackedEntity.createdAt ? new Date(trackedEntity.createdAt) : new Date(), diff --git a/src/data/repositories/utils/getAllTrackedEntities.ts b/src/data/repositories/utils/getAllTrackedEntities.ts index 31e26798..845602b6 100644 --- a/src/data/repositories/utils/getAllTrackedEntities.ts +++ b/src/data/repositories/utils/getAllTrackedEntities.ts @@ -30,6 +30,9 @@ export async function getAllTrackedEntitiesAsync( orgUnit: true, trackedEntity: true, trackedEntityType: true, + enrollments: { + status: true, + }, }, }) .getData(); diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index 8b74b604..f9181936 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -19,7 +19,7 @@ export const hazardTypes = [ export type HazardType = (typeof hazardTypes)[number]; -export enum IncidentStatus { +export enum NationalIncidentStatus { RTSL_ZEB_OS_INCIDENT_STATUS_WATCH = "RTSL_ZEB_OS_INCIDENT_STATUS_WATCH", RTSL_ZEB_OS_INCIDENT_STATUS_ALERT = "RTSL_ZEB_OS_INCIDENT_STATUS_ALERT", RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND = "RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND", @@ -54,6 +54,7 @@ type EarlyResponseActions = { }; export type DiseaseOutbreakEventBaseAttrs = NamedRef & { + status: "ACTIVE" | "COMPLETED" | "CANCELLED"; created: Date; lastUpdated: Date; createdByName: Maybe; @@ -64,7 +65,7 @@ export type DiseaseOutbreakEventBaseAttrs = NamedRef & { notificationSourceCode: Code; areasAffectedProvinceIds: Id[]; areasAffectedDistrictIds: Id[]; - incidentStatus: IncidentStatus; + incidentStatus: NationalIncidentStatus; emerged: DateWithNarrative; detected: DateWithNarrative; notified: DateWithNarrative; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts index 49044cb8..8409e199 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts @@ -12,7 +12,7 @@ export type DiseaseOutbreakEventOptions = { suspectedDiseases: Option[]; notificationSources: Option[]; incidentStatus: Option[]; - teamMembers: TeamMember[]; + incidentManagers: TeamMember[]; }; export type DiseaseOutbreakEventLabels = { diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts new file mode 100644 index 00000000..97b317a7 --- /dev/null +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -0,0 +1,69 @@ +import { Id } from "../Ref"; + +export type DiseaseNames = + | "AFP" + | "Acute VHF" + | "Acute respiratory" + | "Anthrax" + | "Bacterial meningitis" + | "COVID19" + | "Cholera" + | "Diarrhoea with blood" + | "Measles" + | "Monkeypox" + | "Neonatal tetanus" + | "Plague" + | "SARIs" + | "Typhoid fever" + | "Zika fever"; + +export type HazardNames = + | "Animal type" + | "Human type" + | "Human and Animal type" + | "Environmental type"; + +export type PerformanceOverviewMetrics = { + id: Id; + event: string; + province: string; + duration: string; + manager: string; + cases: string; + deaths: string; + era1: string; + era2: string; + era3: string; + era4: string; + era5: string; + era6: string; + era7: string; + detect7d: string; + notify1d: string; + respond7d: string; + creationDate: string; + suspectedDisease: DiseaseNames; + hazardType: HazardNames; + nationalIncidentStatus: string; +}; + +export type IncidentStatus = "Watch" | "Alert" | "Respond"; + +type BaseCounts = { + name: DiseaseNames | HazardNames; + total: number; + incidentStatus: IncidentStatus; + type: "disease" | "hazard"; +}; + +type DiseaseCounts = BaseCounts & { + name: DiseaseNames; + type: "disease"; +}; + +type HazardCounts = BaseCounts & { + name: HazardNames; + type: "hazard"; +}; + +export type TotalCardCounts = DiseaseCounts | HazardCounts; diff --git a/src/domain/repositories/AlertRepository.ts b/src/domain/repositories/AlertRepository.ts index c0805e88..3725603f 100644 --- a/src/domain/repositories/AlertRepository.ts +++ b/src/domain/repositories/AlertRepository.ts @@ -3,7 +3,7 @@ import { Maybe } from "../../utils/ts-utils"; import { Alert } from "../entities/alert/Alert"; import { DataSource, - IncidentStatus, + NationalIncidentStatus, } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../entities/Ref"; @@ -15,6 +15,6 @@ export type AlertOptions = { dataSource: DataSource; eventId: Id; hazardTypeCode: Maybe; - incidentStatus: IncidentStatus; + incidentStatus: NationalIncidentStatus; suspectedDiseaseCode: Maybe; }; diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts new file mode 100644 index 00000000..88c3d101 --- /dev/null +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -0,0 +1,13 @@ +import { FutureData } from "../../data/api-futures"; +import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + TotalCardCounts, + PerformanceOverviewMetrics, +} from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; + +export interface PerformanceOverviewRepository { + getPerformanceOverviewMetrics( + diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] + ): FutureData; + getTotalCardCounts(filters?: Record): FutureData; +} diff --git a/src/domain/repositories/TeamMemberRepository.ts b/src/domain/repositories/TeamMemberRepository.ts index 9a3ecb66..f84191a3 100644 --- a/src/domain/repositories/TeamMemberRepository.ts +++ b/src/domain/repositories/TeamMemberRepository.ts @@ -5,4 +5,5 @@ import { Id } from "../entities/Ref"; export interface TeamMemberRepository { getAll(): FutureData; get(id: Id): FutureData; + getIncidentManagers(): FutureData; } diff --git a/src/domain/usecases/GetAllPerformanceOverviewMetricsUseCase.ts b/src/domain/usecases/GetAllPerformanceOverviewMetricsUseCase.ts new file mode 100644 index 00000000..6bef413e --- /dev/null +++ b/src/domain/usecases/GetAllPerformanceOverviewMetricsUseCase.ts @@ -0,0 +1,23 @@ +import { FutureData } from "../../data/api-futures"; +import { PerformanceOverviewMetrics } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { PerformanceOverviewRepository } from "../repositories/PerformanceOverviewRepository"; + +export class GetAllPerformanceOverviewMetricsUseCase { + constructor( + private options: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + performanceOverviewRepository: PerformanceOverviewRepository; + } + ) {} + + public execute(): FutureData { + return this.options.diseaseOutbreakEventRepository + .getAll() + .flatMap(diseaseOutbreakEvents => { + return this.options.performanceOverviewRepository.getPerformanceOverviewMetrics( + diseaseOutbreakEvents + ); + }); + } +} diff --git a/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts index 6f54b8c9..aab268c3 100644 --- a/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakWithOptionsUseCase.ts @@ -41,7 +41,7 @@ export class GetDiseaseOutbreakWithOptionsUseCase { suspectedDiseases: this.options.optionsRepository.getSuspectedDiseases(), notificationSources: this.options.optionsRepository.getNotificationSources(), incidentStatus: this.options.optionsRepository.getIncidentStatus(), - teamMembers: this.options.teamMemberRepository.getAll(), + incidentManagers: this.options.teamMemberRepository.getIncidentManagers(), }).flatMap( ({ dataSources, @@ -50,13 +50,13 @@ export class GetDiseaseOutbreakWithOptionsUseCase { suspectedDiseases, notificationSources, incidentStatus, - teamMembers, + incidentManagers, }) => { const diseaseOutbreakEventWithOptions: DiseaseOutbreakEventWithOptions = { diseaseOutbreakEvent: diseaseOutbreakEventBase, options: { dataSources, - teamMembers, + incidentManagers, hazardTypes, mainSyndromes, suspectedDiseases, diff --git a/src/domain/usecases/GetDiseasesTotalUseCase.ts b/src/domain/usecases/GetDiseasesTotalUseCase.ts new file mode 100644 index 00000000..b241d3e1 --- /dev/null +++ b/src/domain/usecases/GetDiseasesTotalUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +import { TotalCardCounts } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { PerformanceOverviewRepository } from "../repositories/PerformanceOverviewRepository"; + +export class GetTotalCardCountsUseCase { + constructor(private performanceOverviewRepository: PerformanceOverviewRepository) {} + + public execute(filters?: Record): FutureData { + return this.performanceOverviewRepository.getTotalCardCounts(filters); + } +} diff --git a/src/domain/usecases/__tests__/MapDiseaseOutbreakToAlertsUseCase.spec.ts b/src/domain/usecases/__tests__/MapDiseaseOutbreakToAlertsUseCase.spec.ts index 42496567..c50d1b64 100644 --- a/src/domain/usecases/__tests__/MapDiseaseOutbreakToAlertsUseCase.spec.ts +++ b/src/domain/usecases/__tests__/MapDiseaseOutbreakToAlertsUseCase.spec.ts @@ -1,7 +1,7 @@ import { getTestCompositionRoot } from "../../../CompositionRoot"; import { DataSource, - IncidentStatus, + NationalIncidentStatus, } from "../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; describe("MapDiseaseOutbreakToAlertsUseCase", () => { @@ -13,7 +13,7 @@ describe("MapDiseaseOutbreakToAlertsUseCase", () => { dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, hazardType: "Biological:Human", suspectedDiseaseCode: "", - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, }) .run( () => fail("Should not reach here"), @@ -29,7 +29,7 @@ describe("MapDiseaseOutbreakToAlertsUseCase", () => { dataSource: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, hazardType: "Biological:Human", suspectedDiseaseCode: "", - incidentStatus: IncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, + incidentStatus: NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH, }) .run( data => expect(data).toBeUndefined(), diff --git a/src/types/d2-api.ts b/src/types/d2-api.ts index 54456684..66d48a98 100644 --- a/src/types/d2-api.ts +++ b/src/types/d2-api.ts @@ -6,4 +6,5 @@ export { D2Api } from "@eyeseetea/d2-api/2.36"; export type { D2UserSchema } from "@eyeseetea/d2-api/2.36"; export type { D2TrackedEntityAttributeSchema } from "@eyeseetea/d2-api/2.36"; export type { MetadataPick } from "@eyeseetea/d2-api/2.36"; +export type { AnalyticsResponse } from "@eyeseetea/d2-api/2.36"; export const getMockApi = getMockApiFromClass(D2Api); diff --git a/src/webapp/components/date-picker/DateRangePicker.tsx b/src/webapp/components/date-picker/DateRangePicker.tsx new file mode 100644 index 00000000..c10a22cd --- /dev/null +++ b/src/webapp/components/date-picker/DateRangePicker.tsx @@ -0,0 +1,160 @@ +import i18n from "../../../utils/i18n"; +import React, { useState, useEffect, useMemo } from "react"; +import { Popover, InputAdornment, TextField } from "@material-ui/core"; +import moment from "moment"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { DatePicker } from "./DatePicker"; +import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; +import { IconCalendar24 } from "@dhis2/ui"; +import { Button } from "../button/Button"; +import styled from "styled-components"; + +type DateRangePickerProps = { + value: string[]; + onChange: (dates: string[]) => void; + placeholder?: string; +}; + +export const DateRangePicker: React.FC = React.memo( + ({ value, placeholder = "", onChange }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + useEffect(() => { + if (!value || value.length !== 2) { + setStartDate(moment().startOf("month").toDate()); + setEndDate(moment().toDate()); + } + }, [value]); + + // Adjust startDate if endDate < startDate + useEffect(() => { + if (endDate && startDate && moment(endDate).isBefore(startDate)) { + setStartDate(endDate); + } + }, [startDate, endDate]); + + const handleOpen = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const formatDurationValue = useMemo(() => { + if (!value || value.length !== 2) { + return placeholder; + } + + return `${moment(startDate).format("DD/MM/yyyy")} — ${moment(endDate).format( + "DD/MM/yyyy" + )}`; + }, [startDate, endDate, placeholder, value]); + + const onReset = () => { + onChange([]); + setAnchorEl(null); + }; + + const onSave = () => { + if (startDate && endDate) { + setAnchorEl(null); + onChange([ + moment(startDate).format("YYYY-MM-DD"), + moment(endDate).format("YYYY-MM-DD"), + ]); + } + }; + + return ( + + + + + + ), + }} + /> + + + + + setStartDate(date)} + /> + setEndDate(date)} + /> + + + + + + + + + ); + } +); + +const TextFieldContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const PopoverContainer = styled.div` + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; +`; + +const Container = styled.div` + width: 100%; + display: flex; + justify-content: space-between; +`; + +const StyledTextField = styled(TextField)` + height: 40px; + .MuiOutlinedInput-root { + height: 40px; + } + .MuiFormHelperText-root { + color: ${props => props.theme.palette.common.grey700}; + } + .MuiInputBase-input { + padding-inline: 12px; + padding-block: 10px; + } +`; diff --git a/src/webapp/components/form/FormLayout.tsx b/src/webapp/components/form/FormLayout.tsx index 72fa5c36..adc0476f 100644 --- a/src/webapp/components/form/FormLayout.tsx +++ b/src/webapp/components/form/FormLayout.tsx @@ -44,14 +44,14 @@ export const FormLayout: React.FC = React.memo( + {onCancel && ( )} - @@ -77,7 +77,8 @@ const Footer = styled.div``; const ButtonsFooter = styled.div` margin-block-start: 48px; display: flex; - justify-content: space-between; + justify-content: flex-start; + gap: 16px; `; const TitleContainer = styled.div` diff --git a/src/webapp/components/form/form-summary/FormSummary.tsx b/src/webapp/components/form/form-summary/FormSummary.tsx index 8f9b9520..ad05f63b 100644 --- a/src/webapp/components/form/form-summary/FormSummary.tsx +++ b/src/webapp/components/form/form-summary/FormSummary.tsx @@ -91,6 +91,12 @@ export const FormSummary: React.FC = React.memo(props => { )} + + + {i18n.t("Notes")}: + {" "} + {formSummary.notes} + ) : ( @@ -114,3 +120,7 @@ const SummaryColumn = styled.div` color: ${props => props.theme.palette.text.hint}; min-width: fit-content; `; + +const StyledType = styled(Typography)` + color: ${props => props.theme.palette.text.hint}; +`; diff --git a/src/webapp/components/form/form-summary/useFormSummary.ts b/src/webapp/components/form/form-summary/useFormSummary.ts index 189f2619..7642ed6a 100644 --- a/src/webapp/components/form/form-summary/useFormSummary.ts +++ b/src/webapp/components/form/form-summary/useFormSummary.ts @@ -24,6 +24,7 @@ type FormSummary = { subTitle: string; summary: LabelWithValue[]; incidentManager: Maybe; + notes: string; }; export function useFormSummary(id: Id) { const { compositionRoot } = useAppContext(); @@ -83,6 +84,7 @@ export function useFormSummary(id: Id) { incidentManager: diseaseOutbreakEvent.incidentManager ? mapTeamMemberToUser(diseaseOutbreakEvent.incidentManager) : undefined, + notes: diseaseOutbreakEvent.notes ?? "", }; }; diff --git a/src/webapp/components/stats-card/StatsCard.tsx b/src/webapp/components/stats-card/StatsCard.tsx index de706cc8..c6d82d27 100644 --- a/src/webapp/components/stats-card/StatsCard.tsx +++ b/src/webapp/components/stats-card/StatsCard.tsx @@ -2,14 +2,15 @@ import React from "react"; import { CardContent, Card } from "@material-ui/core"; import styled from "styled-components"; -type StatsCardProps = { - color?: "normal" | "green" | "red"; +export type StatsCardProps = { + color?: "normal" | "green" | "red" | "grey"; stat: string; pretitle?: string; title: string; subtitle?: string; isPercentage?: boolean; error?: boolean; + fillParent?: boolean; }; export const StatsCard: React.FC = React.memo( @@ -21,11 +22,12 @@ export const StatsCard: React.FC = React.memo( color = "normal", isPercentage = false, error = false, + fillParent = false, }) => { return ( - + - {`${stat}${isPercentage ? " %" : ""}`} + {`${stat}${isPercentage ? "%" : ""}`} {pretitle} @@ -38,8 +40,8 @@ export const StatsCard: React.FC = React.memo( } ); -const StyledCard = styled(Card)<{ $error?: boolean }>` - width: fit-content; +const StyledCard = styled(Card)<{ $error?: boolean; $fillParent?: boolean }>` + width: ${props => (props.$fillParent ? "100%" : "fit-content")}; min-width: 220px; max-width: 300px; border-style: ${props => (props.$error ? "solid" : "none")}; diff --git a/src/webapp/components/table/statistic-table/StatisticTable.tsx b/src/webapp/components/table/statistic-table/StatisticTable.tsx index f82a2e9d..15b33579 100644 --- a/src/webapp/components/table/statistic-table/StatisticTable.tsx +++ b/src/webapp/components/table/statistic-table/StatisticTable.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Dispatch, SetStateAction, useCallback } from "react"; import styled from "styled-components"; import i18n from "../../../../utils/i18n"; import { @@ -8,6 +8,7 @@ import { TableHead, TableRow, TableContainer, + TableSortLabel, } from "@material-ui/core"; import { SearchInput } from "../../search-input/SearchInput"; import { MultipleSelector } from "../../selector/MultipleSelector"; @@ -16,8 +17,11 @@ import { useTableCell } from "./useTableCell"; import { useStatisticCalculations } from "./useStatisticCalculations"; import { ColoredCell } from "./ColoredCell"; import { CalculationRow } from "./CalculationRow"; +import { Order } from "../../../pages/dashboard/usePerformanceOverview"; +import { Option } from "../../utils/option"; import { Id } from "../../../../domain/entities/Ref"; import { Maybe } from "../../../../utils/ts-utils"; +import { PerformanceOverviewMetrics } from "../../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; export type TableColumn = { value: string; @@ -25,10 +29,12 @@ export type TableColumn = { dark?: boolean; }; -export type FilterType = { +export type FiltersConfig = { value: TableColumn["value"]; label: TableColumn["label"]; type: "multiselector" | "datepicker"; + options?: Option[]; + disabled?: boolean; }; export type FiltersValuesType = { @@ -44,7 +50,9 @@ export type StatisticTableProps = { rows: { [key: TableColumn["value"]]: string; }[]; - filters: FilterType[]; + filters: FiltersConfig[]; + order: Maybe; + setOrder: Dispatch>>; goToEvent: (id: Maybe) => void; }; @@ -55,6 +63,8 @@ export const StatisticTable: React.FC = React.memo( columnRules, editRiskAssessmentColumns, filters: filtersConfig, + order, + setOrder, goToEvent, }) => { const calculateColumns = [...editRiskAssessmentColumns, ...Object.keys(columnRules)]; @@ -67,6 +77,23 @@ export const StatisticTable: React.FC = React.memo( columnRules ); + const onOrderBy = useCallback( + (value: string) => { + setOrder(prevOrder => { + return { + name: value as keyof PerformanceOverviewMetrics, + direction: + prevOrder?.name === value + ? order?.direction === "asc" + ? "desc" + : "asc" + : "asc", + }; + }); + }, + [order, setOrder] + ); + return ( @@ -85,12 +112,26 @@ export const StatisticTable: React.FC = React.memo( setSearchTerm(value)} /> - +
{columns.map(({ value, label, dark = false }) => ( - - {label} + + onOrderBy(value)} + > + {label} + ))} @@ -112,7 +153,7 @@ export const StatisticTable: React.FC = React.memo( goToEvent(row.id)} key={`${rowIndex}-${column.value}`} - boldUnderline={columnIndex === 0} + $link={columnIndex === 0} > {row[column.value] || ""} @@ -171,9 +212,10 @@ const HeadTableCell = styled(TableCell)<{ $dark?: boolean }>` font-weight: 600; `; -const StyledTableCell = styled(TableCell)<{ boldUnderline?: boolean }>` - text-decoration: ${props => (props.boldUnderline ? "underline" : "none")}; - font-weight: ${props => (props.boldUnderline ? "600" : "initial")}; +const StyledTableCell = styled(TableCell)<{ $link?: boolean }>` + text-decoration: ${props => (props.$link ? "underline" : "none")}; + cursor: ${props => (props.$link ? "pointer" : "initial")}; + font-weight: ${props => (props.$link ? "600" : "initial")}; `; const Container = styled.div` diff --git a/src/webapp/components/table/statistic-table/useStatisticCalculations.ts b/src/webapp/components/table/statistic-table/useStatisticCalculations.ts index e4fd1c88..d1aebb0f 100644 --- a/src/webapp/components/table/statistic-table/useStatisticCalculations.ts +++ b/src/webapp/components/table/statistic-table/useStatisticCalculations.ts @@ -5,9 +5,14 @@ export const useStatisticCalculations = ( rows: StatisticTableProps["rows"], columnRules: { [key: string]: number } ) => { + const getFilteredRowsByColumn = useCallback( + (column: string) => rows.filter(row => row[column] !== ""), + [rows] + ); + const calculateMedian = useCallback( (column: string) => { - const values = rows.map(row => Number(row[column])).filter(value => !isNaN(value)); + const values = getFilteredRowsByColumn(column).map(row => Number(row[column])); values.sort((a, b) => a - b); const mid = Math.floor(values.length / 2); return ( @@ -16,17 +21,19 @@ export const useStatisticCalculations = ( : ((values[mid - 1] || 0) + (values[mid] || 0)) / 2) || 0 ); }, - [rows] + [getFilteredRowsByColumn] ); const calculatePercentTargetMet = useCallback( (column: string) => { + const filteredRows = getFilteredRowsByColumn(column); const target = columnRules[column] || 7; - const count = rows.filter(row => Number(row[column]) <= target).length; - const percentage = (count / rows.length) * 100 || 0; + const count = filteredRows.filter(row => Number(row[column]) <= target).length; + + const percentage = (count / filteredRows.length) * 100 || 0; return `${percentage.toFixed(0) || 0}%`; }, - [rows, columnRules] + [getFilteredRowsByColumn, columnRules] ); return { diff --git a/src/webapp/components/table/statistic-table/useTableFilters.ts b/src/webapp/components/table/statistic-table/useTableFilters.ts index b1664791..3c4e27e7 100644 --- a/src/webapp/components/table/statistic-table/useTableFilters.ts +++ b/src/webapp/components/table/statistic-table/useTableFilters.ts @@ -1,8 +1,16 @@ import { useCallback, useMemo, useState } from "react"; import _ from "../../../../domain/entities/generic/Collection"; -import { FiltersValuesType, FilterType, StatisticTableProps, TableColumn } from "./StatisticTable"; - -export const useTableFilters = (rows: StatisticTableProps["rows"], filtersConfig: FilterType[]) => { +import { + FiltersValuesType, + FiltersConfig, + StatisticTableProps, + TableColumn, +} from "./StatisticTable"; + +export const useTableFilters = ( + rows: StatisticTableProps["rows"], + filtersConfig: FiltersConfig[] +) => { const [searchTerm, setSearchTerm] = useState(""); const [filters, setFilters] = useState( diff --git a/src/webapp/pages/app/themes/dhis2.theme.ts b/src/webapp/pages/app/themes/dhis2.theme.ts index 0d17c3c5..e6002d57 100644 --- a/src/webapp/pages/app/themes/dhis2.theme.ts +++ b/src/webapp/pages/app/themes/dhis2.theme.ts @@ -174,6 +174,7 @@ const palette = { green: colors.green, red: colors.red, normal: colors.green700, + grey: colors.grey900, title: colors.black, subtitle: colors.grey3, pretitle: colors.grey3, diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index f6245c0c..a63c017a 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -5,40 +5,108 @@ import { Layout } from "../../components/layout/Layout"; import { Section } from "../../components/section/Section"; import { StatisticTable } from "../../components/table/statistic-table/StatisticTable"; import { usePerformanceOverview } from "./usePerformanceOverview"; -import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { useCardCounts } from "./useCardCounts"; +import { StatsCard } from "../../components/stats-card/StatsCard"; +import styled from "styled-components"; +import { MultipleSelector } from "../../components/selector/MultipleSelector"; import { Id } from "../../../domain/entities/Ref"; import { Maybe } from "../../../utils/ts-utils"; +import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { useFilters } from "./useFilters"; +import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; export const DashboardPage: React.FC = React.memo(() => { + const { filters, filterOptions, setFilters } = useFilters(); const { columns, dataPerformanceOverview, - filters, + filters: performanceOverviewFilters, + order, + setOrder, columnRules, editRiskAssessmentColumns, - isLoading, } = usePerformanceOverview(); + const { cardCounts } = useCardCounts(filters); + const { goTo } = useRoutes(); const goToEvent = (id: Maybe) => { if (!id) return; - goTo(RouteName.EVENT_TRACKER, { id: id }); //TO DO : Change to dynamic formType when available + goTo(RouteName.EVENT_TRACKER, { id }); }; + return ( - {/*
Respond, alert, watch content
*/} +
+ + {filterOptions.map(({ value, label, options, disabled }) => ( + + setFilters({ + ...filters, + [value]: values, + }) + } + disabled={disabled} + /> + ))} + setFilters({ ...filters, duration: dates })} + placeholder={i18n.t("Duration")} + /> + + + {cardCounts.map((cardCount, index) => ( + + ))} + +
+
TBD
- {isLoading ?
Loading...
: null} - + + +
); }); + +const GridWrapper = styled.div` + width: 100%; + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.5rem; +`; + +const StatisticTableWrapper = styled.div` + display: grid; +`; + +const Container = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + gap: 1rem; +`; diff --git a/src/webapp/pages/dashboard/useCardCounts.ts b/src/webapp/pages/dashboard/useCardCounts.ts new file mode 100644 index 00000000..fa990bed --- /dev/null +++ b/src/webapp/pages/dashboard/useCardCounts.ts @@ -0,0 +1,31 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import _ from "../../../domain/entities/generic/Collection"; +import { TotalCardCounts } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; + +export type Order = { name: string; direction: "asc" | "desc" }; + +export function useCardCounts(filters: Record) { + const { compositionRoot } = useAppContext(); + const [cardCounts, setCardCounts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + compositionRoot.performanceOverview.getTotalCardCounts.execute(filters).run( + diseasesTotal => { + setCardCounts(diseasesTotal); + setIsLoading(false); + }, + error => { + console.error({ error }); + setIsLoading(false); + } + ); + }, [compositionRoot, filters]); + + return { + cardCounts, + isLoading, + }; +} diff --git a/src/webapp/pages/dashboard/useFilters.ts b/src/webapp/pages/dashboard/useFilters.ts new file mode 100644 index 00000000..b801f899 --- /dev/null +++ b/src/webapp/pages/dashboard/useFilters.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from "react"; +import { FiltersConfig } from "../../components/table/statistic-table/StatisticTable"; +import { evenTrackerCountsIndicatorMap } from "../../../data/repositories/consts/PerformanceOverviewConstants"; +import _c from "../../../domain/entities/generic/Collection"; + +export function useFilters() { + const [filters, setFilters] = useState>({}); + const [filterOptions, setFilterOptions] = useState([]); + + const buildFilterOptions = useCallback((): FiltersConfig[] => { + const createOptions = (key: "disease" | "hazard") => + _c(evenTrackerCountsIndicatorMap) + .filter(value => value.type === key) + .uniqBy(value => value.name) + .map(value => ({ + value: value.name, + label: value.name, + })) + + .value(); + + const diseaseOptions = createOptions("disease"); + const hazardOptions = createOptions("hazard"); + + return [ + { + value: "incidentStatus", + label: "Incident Status", + type: "multiselector", + options: [ + { value: "Respond", label: "Respond" }, + { value: "Alert", label: "Alert" }, + { value: "Watch", label: "Watch" }, + ], + }, + { + value: "disease", + label: "Disease", + type: "multiselector", + options: diseaseOptions, + }, + { + value: "hazard", + label: "Hazard Type", + type: "multiselector", + options: hazardOptions, + }, + ]; + }, []); + + const handleSetFilters = useCallback( + (newFilters: Record) => { + setFilters(newFilters); + setFilterOptions( + filterOptions.map(option => ({ + ...option, + disabled: + (newFilters.disease && newFilters.disease.length > 0 + ? option.value === "hazard" + : false) || + (newFilters.hazard && newFilters.hazard.length > 0 + ? option.value === "disease" + : false), + })) + ); + }, + [filterOptions] + ); + + // Initialize filter options based on diseasesTotal + useEffect(() => { + setFilterOptions(buildFilterOptions()); + }, [buildFilterOptions]); + + return { filters, filterOptions, setFilters: handleSetFilters }; +} diff --git a/src/webapp/pages/dashboard/usePerformanceOverview.ts b/src/webapp/pages/dashboard/usePerformanceOverview.ts index dc94f2fd..44dddddf 100644 --- a/src/webapp/pages/dashboard/usePerformanceOverview.ts +++ b/src/webapp/pages/dashboard/usePerformanceOverview.ts @@ -1,53 +1,90 @@ -import { useEffect, useState } from "react"; +import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; - -import { FilterType, TableColumn } from "../../components/table/statistic-table/StatisticTable"; -import { Id } from "../../../domain/entities/Ref"; +import _ from "../../../domain/entities/generic/Collection"; +import { FiltersConfig, TableColumn } from "../../components/table/statistic-table/StatisticTable"; +import { Maybe } from "../../../utils/ts-utils"; +import { PerformanceOverviewMetrics } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { NationalIncidentStatus } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type State = { columns: TableColumn[]; - dataPerformanceOverview: any[]; + dataPerformanceOverview: PerformanceOverviewMetrics[]; columnRules: { [key: string]: number }; editRiskAssessmentColumns: string[]; - filters: FilterType[]; + filters: FiltersConfig[]; + order: Maybe; + setOrder: Dispatch>>; isLoading: boolean; }; -type PerformanceOverviewData = { - id: Id; - event: string; - location: string; - cases: string; - deaths: string; - duration: string; - manager: string; - detect7d: string; - notify1d: string; - era1: string; - era2: string; - era3: string; - era4: string; - era5: string; - era6: string; - era7: string; - eri: string; - respond7d: string; -}; +export type Order = { name: keyof PerformanceOverviewMetrics; direction: "asc" | "desc" }; + export function usePerformanceOverview(): State { const { compositionRoot } = useAppContext(); const [dataPerformanceOverview, setDataPerformanceOverview] = useState< - PerformanceOverviewData[] + PerformanceOverviewMetrics[] >([]); const [isLoading, setIsLoading] = useState(false); + const [order, setOrder] = useState(); + + useEffect(() => { + if (dataPerformanceOverview.length && order) { + setDataPerformanceOverview( + (prevDataPerformanceOverview: PerformanceOverviewMetrics[]) => { + const newDataPerformanceOverview = _(prevDataPerformanceOverview) + .orderBy([ + [ + item => + Number.isNaN(Number(item[order.name])) + ? item[order.name] + : Number(item[order.name]), + order.direction, + ], + ]) + .toArray(); + return newDataPerformanceOverview; + } + ); + } + }, [order, dataPerformanceOverview]); + + const getNationalIncidentStatusString = useCallback((status: string): string => { + switch (status as NationalIncidentStatus) { + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_ALERT: + return "Alert"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_CLOSED: + return "Closed"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_DISCARDED: + return "Discarded"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_RESPOND: + return "Respond"; + case NationalIncidentStatus.RTSL_ZEB_OS_INCIDENT_STATUS_WATCH: + return "Watch"; + } + }, []); + + const mapEntityToTableData = useCallback( + (programIndicator: PerformanceOverviewMetrics): PerformanceOverviewMetrics => { + return { + ...programIndicator, + nationalIncidentStatus: getNationalIncidentStatusString( + programIndicator.nationalIncidentStatus + ), + event: programIndicator.event, + }; + }, + [getNationalIncidentStatusString] + ); useEffect(() => { setIsLoading(true); - compositionRoot.diseaseOutbreakEvent.getAll.execute().run( - diseaseOutbreakEvent => { - setDataPerformanceOverview( - diseaseOutbreakEvent.map((data, i) => mapEntityToTableData(data, !i)) + compositionRoot.performanceOverview.getPerformanceOverviewMetrics.execute().run( + programIndicators => { + const mappedData = programIndicators.map((data: PerformanceOverviewMetrics) => + mapEntityToTableData(data) ); + setDataPerformanceOverview(mappedData); setIsLoading(false); }, error => { @@ -55,11 +92,11 @@ export function usePerformanceOverview(): State { setIsLoading(false); } ); - }, [compositionRoot.diseaseOutbreakEvent.getAll]); + }, [compositionRoot.performanceOverview.getPerformanceOverviewMetrics, mapEntityToTableData]); const columns: TableColumn[] = [ { label: "Event", value: "event" }, - { label: "Location", value: "location" }, + { label: "Province", value: "province" }, { label: "Cases", value: "cases" }, { label: "Deaths", value: "deaths" }, { label: "Duration", value: "duration" }, @@ -73,57 +110,26 @@ export function usePerformanceOverview(): State { { label: "ERA5", value: "era5" }, { label: "ERA6", value: "era6" }, { label: "ERA7", value: "era7" }, - { label: "ERI", value: "eri" }, { label: "Respond 7d", dark: true, value: "respond7d" }, + { label: "Incident Status", value: "nationalIncidentStatus" }, ]; - const editRiskAssessmentColumns = [ - "era1", - "era2", - "era3", - "era4", - "era5", - "era6", - "era7", - "eri", - ]; + const editRiskAssessmentColumns = ["era1", "era2", "era3", "era4", "era5", "era6", "era7"]; const columnRules: { [key: string]: number } = { detect7d: 7, notify1d: 1, respond7d: 7, }; - const mapEntityToTableData = (diseaseOutbreakEvent: any, blank = false) => { - const getRandom = (max: number) => Math.floor(Math.random() * max).toString(); - - return { - id: diseaseOutbreakEvent.id, - event: diseaseOutbreakEvent.name, - location: "TBD", - cases: "TBD", - deaths: "TBD", - duration: "TBD", - manager: diseaseOutbreakEvent.createdByName || "TBD", - detect7d: getRandom(12), - notify1d: getRandom(7), - era1: getRandom(14), - era2: blank ? "" : getRandom(14), - era3: blank ? "" : getRandom(14), - era4: blank ? "" : getRandom(14), - era5: blank ? "" : getRandom(14), - era6: blank ? "" : getRandom(14), - era7: blank ? "" : getRandom(14), - eri: blank ? "" : getRandom(14), - respond7d: getRandom(14), - }; - }; - const filters: FilterType[] = [ + const filters: FiltersConfig[] = [ { value: "event", label: "Event", type: "multiselector" }, - { value: "location", label: "Location", type: "multiselector" }, + { value: "province", label: "Province", type: "multiselector" }, ]; return { dataPerformanceOverview, filters, + order, + setOrder, columnRules, editRiskAssessmentColumns, columns, diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/mapEntityToInitialFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/utils/mapEntityToInitialFormState.ts index 6075a9d1..396f871f 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/mapEntityToInitialFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/utils/mapEntityToInitialFormState.ts @@ -86,7 +86,7 @@ export function mapEntityToInitialFormState( const { diseaseOutbreakEvent, options } = diseaseOutbreakEventWithOptions; const { dataSources, - teamMembers, + incidentManagers, hazardTypes, mainSyndromes, suspectedDiseases, @@ -94,7 +94,7 @@ export function mapEntityToInitialFormState( incidentStatus, } = options; - const teamMemberOptions: User[] = teamMembers.map(tm => mapTeamMemberToUser(tm)); + const teamMemberOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); const dataSourcesOptions: PresentationOption[] = mapToPresentationOptions(dataSources); const hazardTypesOptions: PresentationOption[] = mapToPresentationOptions(hazardTypes); diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/disease-outbreak-event/utils/mapFormStateToEntityData.ts index 58a358e2..2bc5df0e 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/utils/mapFormStateToEntityData.ts @@ -153,6 +153,7 @@ export function mapFormStateToEntityData( const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { id: diseaseOutbreakEvent?.id || "", + status: diseaseOutbreakEvent?.status || "ACTIVE", created: diseaseOutbreakEvent?.created || new Date(), lastUpdated: diseaseOutbreakEvent?.lastUpdated || new Date(), createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, diff --git a/yarn.lock b/yarn.lock index cfcf1574..89e95e32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3419,10 +3419,10 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.0.tgz#a5417ae8427873f1dd08b70b3574b453e67b5f7f" integrity sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g== -"@eyeseetea/d2-api@1.16.0-beta.9": - version "1.16.0-beta.9" - resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.16.0-beta.9.tgz#bd318b62d8c94ea4e490c8e9b90461869f748c25" - integrity sha512-ASOcekMZoOOAZ9+Aq4I34qkiHW4sZFWoB5V8V51WAgYTv0ONoS8uLdFpmc1kFXJ5jsWC7IgSnE79rkLLZ9h9mg== +"@eyeseetea/d2-api@1.16.0-beta.12": + version "1.16.0-beta.12" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.16.0-beta.12.tgz#02fd26e7a28f2debf7d890364a077020e4eab7b7" + integrity sha512-5VlaiWPrpuIHlCKGB75H4ndd7W0s203CR7qBSmfcqAMhmKnybPI7tTB97DJxF+VnIxwRvfsZ0g8ATKVlUTz0Vg== dependencies: "@babel/runtime" "^7.5.4" "@dhis2/d2-i18n" "^1.0.5" @@ -8798,7 +8798,7 @@ moment@2.29.3: resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.3.tgz#edd47411c322413999f7a5940d526de183c031f3" integrity sha512-c6YRvhEo//6T2Jz/vVtYzqBzwvPT95JBQ+smCytzf7c50oMZRsR/a4w88aD34I+/QVSfnoAnSBFPJHItlOMJVw== -moment@^2.22.1, moment@^2.24.0, moment@^2.29.1, moment@^2.29.4: +moment@^2.22.1, moment@^2.24.0, moment@^2.29.1, moment@^2.29.4, moment@^2.30.1: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==