From 9c15b7e86d2ac1cb4adb23085a0c92726d254e53 Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Mon, 16 Sep 2024 15:53:43 +0200 Subject: [PATCH 01/24] add 717 section to dashboard --- src/CompositionRoot.ts | 2 + .../repositories/AnalyticsD2Repository.ts | 66 + .../repositories/consts/AnalyticsConstants.ts | 1246 +++++++++++++++++ .../test/ProgramIndicatorsTestRepository.ts | 3 + .../repositories/AnalyticsRepository.ts | 8 +- .../usecases/Get717PerformanceUseCase.ts | 25 + src/webapp/pages/dashboard/DashboardPage.tsx | 46 +- .../pages/dashboard/use717Performance.ts | 83 ++ 8 files changed, 1444 insertions(+), 35 deletions(-) create mode 100644 src/domain/usecases/Get717PerformanceUseCase.ts create mode 100644 src/webapp/pages/dashboard/use717Performance.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index be742793..cfed02b8 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -28,6 +28,7 @@ import { GetDiseasesTotalUseCase } from "./domain/usecases/GetDiseasesTotalUseCa import { MapDiseaseOutbreakToAlertsUseCase } from "./domain/usecases/MapDiseaseOutbreakToAlertsUseCase"; import { AlertRepository } from "./domain/repositories/AlertRepository"; import { AlertTestRepository } from "./data/repositories/test/AlertTestRepository"; +import { Get717PerformanceUseCase } from "./domain/usecases/Get717PerformanceUseCase"; export type CompositionRoot = ReturnType; @@ -58,6 +59,7 @@ function getCompositionRoot(repositories: Repositories) { analytics: { getProgramIndicators: new GetAllProgramIndicatorsUseCase(repositories), getDiseasesTotal: new GetDiseasesTotalUseCase(repositories), + get717Performance: new Get717PerformanceUseCase(repositories), }, }; } diff --git a/src/data/repositories/AnalyticsD2Repository.ts b/src/data/repositories/AnalyticsD2Repository.ts index b561a429..7612965f 100644 --- a/src/data/repositories/AnalyticsD2Repository.ts +++ b/src/data/repositories/AnalyticsD2Repository.ts @@ -7,6 +7,7 @@ import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import _ from "../../domain/entities/generic/Collection"; import { Future } from "../../domain/entities/generic/Future"; import { + INDICATORS_717_PERFORMANCE, IndicatorsId, NB_OF_ACTIVE_VERIFIED, NB_OF_CASES, @@ -37,6 +38,12 @@ export type ProgramIndicatorBaseAttrs = { suspectedDisease: string; eventDetectionDate: string; }; +export type Indicator717PerformanceBaseAttrs = { + id: string; + name: string; + type: "count" | "percent"; + value: number; +}; export class AnalyticsD2Repository implements AnalyticsRepository { constructor(private api: D2Api) {} @@ -107,6 +114,65 @@ export class AnalyticsD2Repository implements AnalyticsRepository { }); } + get717Performance( + filters?: Record + ): FutureData { + const transformData = ( + data: string[][], + indicators717Performance: typeof INDICATORS_717_PERFORMANCE + ): Indicator717PerformanceBaseAttrs[] => { + return data + .flatMap(([id, , value]) => { + const indicator = indicators717Performance.find(d => d.id === id); + if (!indicator || !value) { + return []; + } + + // Ensure the type is either 'count' or 'percent' + const type: "count" | "percent" = + indicator.type === "count" || indicator.type === "percent" + ? indicator.type + : "count"; // Default to 'count' if type is not valid + + return [ + { + ...indicator, + value: parseFloat(value), + type, // Set the valid type here with narrowed types + }, + ]; + }) + .filter(item => { + if (!item) { + return false; + } + if (filters) { + return Object.entries(filters).every(([key, values]) => { + if (!values.length) { + return true; + } + if (item[key as keyof typeof item]) { + return values.includes(item[key as keyof typeof item] as string); + } + }); + } + return true; + }); + }; + + return apiToFuture( + this.api.analytics.get({ + dimension: [ + `dx:${INDICATORS_717_PERFORMANCE.map(({ id }) => id).join(";")}`, + "pe:THIS_YEAR", + ], + includeMetadataDetails: true, + }) + ).map(res => { + return transformData(res.rows, INDICATORS_717_PERFORMANCE) || []; + }); + } + getProgramIndicators( diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] ): FutureData { diff --git a/src/data/repositories/consts/AnalyticsConstants.ts b/src/data/repositories/consts/AnalyticsConstants.ts index eae687f9..30432315 100644 --- a/src/data/repositories/consts/AnalyticsConstants.ts +++ b/src/data/repositories/consts/AnalyticsConstants.ts @@ -1,5 +1,6 @@ export enum IndicatorsId { suspectedDisease = "jLvbkuvPdZ6", + hazardType = "Dzrw3Tf0ukB", event = "fyrLOW9Iwwv", era1 = "Ylmo2fEijff", era2 = "w4FOvRAyjEE", @@ -203,3 +204,1248 @@ export const NB_OF_ACTIVE_VERIFIED = [ { id: "z3EbI98pgjG", type: "hazard", name: "Environmental type", incidentStatus: "Alert" }, { id: "gRcZNqpKyYg", type: "hazard", name: "Environmental type", incidentStatus: "Respond" }, ]; + +export const INDICATORS_717_PERFORMANCE = [ + { id: "VWazDAQ15Uw", name: "detection", type: "percent" }, // % of number of alerts that were detected within 7 days of date of emergence + { id: "jnJHR2D0cN3", name: "detection", type: "count" }, // Number of alerts notified to public health authorities within 1 day of detection + + { id: "dr4OT0ql4cl", name: "notification", type: "percent" }, // + { id: "K3cqDGAQwWe", name: "notification", type: "count" }, // # events response action started 1 day + + { id: "PQbR3lpD6my", name: "response", type: "percent" }, // % num of alerts responded d within 7d date not + { id: "ZX0uPp3ik81", name: "response", type: "count" }, // # events response action started 1 day + + { id: "gsHTYKBOLbb", name: "allTargets", type: "percent" }, // % num of alerts detected within 7d date emergence + { id: "FbPj6kekXBj", name: "allTargets", type: "count" }, +]; + +const INDICATORS_717 = [ + { + id: "SnlZWWmSnev", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "AFP", + }, + { + id: "nl6Zlqt3JVM", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + incidentStatus: "Alert", + disease: "AFP", + }, + { + id: "ToQ6DYare5u", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + incidentStatus: "Respond", + disease: "AFP", + }, + { + id: "lFsfgXpIlil", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + incidentStatus: "Watch", + disease: "AFP", + }, + { + id: "nuLeni5o8MP", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute Respiratory", + }, + { + id: "ZPEwiNLRF2f", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute Respiratory", + incidentStatus: "Alert", + }, + { + id: "QNbw22AMaVF", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute Respiratory", + incidentStatus: "Respond", + }, + { + id: "S28T4raFe7I", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute Respiratory", + incidentStatus: "Watch", + }, + { + id: "I7DaAl9tvP7", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute VHF", + }, + { + id: "nkEzRvdeNw0", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute VHF", + incidentStatus: "Alert", + }, + { + id: "b5nzdHny6KE", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute VHF", + incidentStatus: "Respond", + }, + { + id: "ejRL3uLmv7H", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Acute VHF", + incidentStatus: "Watch", + }, + { + id: "zDUstCxMvUO", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Anthrax", + }, + { + id: "uRBJpM3mFIs", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Anthrax", + incidentStatus: "Alert", + }, + { + id: "u4SkulpOQB4", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Anthrax", + incidentStatus: "Watch", + }, + { + id: "XMFJTT0jO4m", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Bacterial Meningitis", + }, + { + id: "KysBTCpMNUz", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Bacterial Meningitis", + incidentStatus: "Alert", + }, + { + id: "NbOk0cxkpvs", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Bacterial Meningitis", + incidentStatus: "Respond", + }, + { + id: "Jlc27BTvUW4", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Bacterial Meningitis", + incidentStatus: "Watch", + }, + { + id: "d4h141lBxPS", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Cholera", + }, + { + id: "rmsp9P4uDPr", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Cholera", + incidentStatus: "Alert", + }, + { + id: "KRMBVYOcdQd", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Cholera", + incidentStatus: "Watch", + }, + { + id: "bXMc1BSXzHp", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Cholera", + incidentStatus: "Respond", + }, + { + id: "AgRt7IJjp0F", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Covid19", + }, + { + id: "kqNy3fZy7RH", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Covid19", + incidentStatus: "Alert", + }, + { + id: "Mf60weIB38l", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Covid19", + incidentStatus: "Respond", + }, + { + id: "xG6Alfb9lK4", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Covid19", + incidentStatus: "Watch", + }, + { + id: "F0n4Cnq7pt3", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Diarrhea with blood", + }, + { + id: "kjvrvYE482f", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Diarrhea with blood", + incidentStatus: "Alert", + }, + { + id: "ip33ZANqpPA", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Diarrhea with blood", + incidentStatus: "Respond", + }, + { + id: "jpsHReIY9Mg", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Diarrhea with blood", + incidentStatus: "Watch", + }, + { + id: "EfAsDeNjNnm", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Measles", + }, + { + id: "U4rOjZVSWp2", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Measles", + incidentStatus: "Alert", + }, + { + id: "ZqtnwH0akDG", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Measles", + incidentStatus: "Respond", + }, + { + id: "dv0JYE1UwXe", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Measles", + incidentStatus: "Watch", + }, + { + id: "L76kMfIlUpY", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Monkeypox", + }, + { + id: "xYYQRymq7iz", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Monkeypox", + incidentStatus: "Alert", + }, + { + id: "QeJBw9kUc9k", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Monkeypox", + incidentStatus: "Respond", + }, + { + id: "jMvLCothXAK", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Monkeypox", + incidentStatus: "Watch", + }, + { + id: "fsUqEop9Fpe", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Neonatal tetanus", + }, + { + id: "DQtrZZXzqvQ", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Neonatal tetanus", + incidentStatus: "Alert", + }, + { + id: "G5iVQOAvV6I", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Neonatal tetanus", + incidentStatus: "Respond", + }, + { + id: "k9HER7gGwx7", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Neonatal tetanus", + incidentStatus: "Watch", + }, + { + id: "zWCJGV5Or6m", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Plague", + }, + { + id: "rFokjuSb3Kx", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Plague", + incidentStatus: "Alert", + }, + { + id: "tMFHefINSiR", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Plague", + incidentStatus: "Respond", + }, + { + id: "ke9HE3bffny", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Plague", + incidentStatus: "Watch", + }, + { + id: "B2qckoEX6m2", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + incidentStatus: "Respond", + }, + { + id: "QZcaLMK9D8A", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "SARIs", + }, + { + id: "gWhrKjOxmAQ", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "SARIs", + incidentStatus: "Alert", + }, + { + id: "ekzSRmlKdm6", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "SARIs", + incidentStatus: "Respond", + }, + { + id: "W0ythNIgdFj", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "SARIs", + incidentStatus: "Watch", + }, + { + id: "t2k9cPlQnns", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Typhoid fever", + }, + { + id: "LRGObRCNgTu", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Typhoid fever", + incidentStatus: "Alert", + }, + { + id: "FVXLF7FqNlJ", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Typhoid fever", + incidentStatus: "Respond", + }, + { + id: "fKmbCH6wv0F", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Typhoid fever", + incidentStatus: "Watch", + }, + { + id: "wTDTbOR8NTz", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Zika fever", + }, + { + id: "MtNNhpMR62L", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Zika fever", + incidentStatus: "Alert", + }, + { + id: "SNPUqZlG5Xl", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Zika fever", + incidentStatus: "Respond", + }, + { + id: "ldTezKD5XYy", + name: "% of number of alerts notified to public health authorities within 1 day of detection", + type: "notification", + disease: "Zika fever", + incidentStatus: "Watch", + }, + { + id: "t4c8Ntac07E", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "notification", + }, + { + id: "RC2jTUJtT7L", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "AFP", + }, + { + id: "Bn9Z9Lr7pZ5", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + incidentStatus: "Alert", + disease: "AFP", + }, + { + id: "Tj04ZY08bPS", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + incidentStatus: "Respond", + disease: "AFP", + }, + { + id: "sHG6xZdYH6A", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + incidentStatus: "Watch", + disease: "AFP", + }, + { + id: "MJ2FPmAWgbd", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute Respiratory", + }, + { + id: "WgTUeyZr3lh", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute Respiratory", + incidentStatus: "Alert", + }, + { + id: "RXa3QVm0iJg", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute Respiratory", + incidentStatus: "Respond", + }, + { + id: "I2OGSbDELey", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute Respiratory", + incidentStatus: "Watch", + }, + { + id: "eoEPuP4f8iK", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute VHF", + }, + { + id: "RUqNg4sGsPq", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute VHF", + incidentStatus: "Alert", + }, + { + id: "APcx2MsAlYr", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute VHF", + incidentStatus: "Respond", + }, + { + id: "D5DiSgxXe1z", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Acute VHF", + incidentStatus: "Watch", + }, + { + id: "pOFn0en3fmJ", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Anthrax", + }, + { + id: "M49f448xNqX", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Anthrax", + incidentStatus: "Alert", + }, + { + id: "lgAGQswA2gz", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Anthrax", + incidentStatus: "Respond", + }, + { + id: "ewJFpaViIfA", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Anthrax", + incidentStatus: "Watch", + }, + { + id: "ytOrBSVhkTM", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Bacterial Meningitis", + }, + { + id: "DsuMoDQd9eG", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Bacterial Meningitis", + incidentStatus: "Alert", + }, + { + id: "qADIfhAuXbE", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Bacterial Meningitis", + incidentStatus: "Respond", + }, + { + id: "PvMa1dFOYIK", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Bacterial Meningitis", + incidentStatus: "Watch", + }, + { + id: "p0W1Run9toi", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Cholera", + }, + { + id: "I94t7uZJJMD", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Cholera", + incidentStatus: "Alert", + }, + { + id: "Wdlal5cmE52", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Cholera", + incidentStatus: "Respond", + }, + { + id: "x8IarVRwu7C", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Cholera", + incidentStatus: "Watch", + }, + { + id: "rgkGha8keXX", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Covid19", + }, + { + id: "QqTuVNZnxy5", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Covid19", + incidentStatus: "Alert", + }, + { + id: "xU5owm2CcDb", + name: "% of number of alerts that were detected within 7 days of date of emergence ", + type: "detection", + disease: "Covid19", + }, + { + id: "UJD6LLiIfKm", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Covid19", + incidentStatus: "Watch", + }, + { + id: "n7Q0XKPhz9D", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Diarrhea with blood", + }, + { + id: "CnfA0qd946B", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Diarrhea with blood", + incidentStatus: "Alert", + }, + { + id: "MNGwSWqwnOZ", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Diarrhea with blood", + incidentStatus: "Respond", + }, + { + id: "OAxlgbhZlb8", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Diarrhea with blood", + incidentStatus: "Watch", + }, + { + id: "MFk8jiMSlfC", + name: "% of number of alerts that were detected within 7 days of date of emergence - Events", + type: "notification", + }, + { + id: "J80pmH2KRcx", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Measles", + }, + { + id: "oxJ5mau1J3x", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Measles", + incidentStatus: "Alert", + }, + { + id: "PXlb8RA8jhM", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Measles", + incidentStatus: "Respond", + }, + { + id: "mP3MBcWPk2x", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Measles", + incidentStatus: "Watch", + }, + { + id: "XMjRmOub0NX", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Monkeypox", + }, + { + id: "ruYWSrhzLgP", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Monkeypox", + incidentStatus: "Alert", + }, + { + id: "q0ZMp98y6y2", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Monkeypox", + incidentStatus: "Respond", + }, + { + id: "rSRYG6orVh9", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Monkeypox", + incidentStatus: "Watch", + }, + { + id: "qI9630naxV6", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Neonatal tetanus", + }, + { + id: "fKirdoLLh1Y", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Neonatal tetanus", + incidentStatus: "Alert", + }, + { + id: "q7oli7A6nn8", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Neonatal tetanus", + incidentStatus: "Respond", + }, + { + id: "PLNANQ0CAPW", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Neonatal tetanus", + incidentStatus: "Watch", + }, + { + id: "lKgkoszdfu7", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Plague", + }, + { + id: "HYmDJlHZd1W", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Plague", + incidentStatus: "Alert", + }, + { + id: "ZQSAJrQMkug", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Plague", + incidentStatus: "Respond", + }, + { + id: "eiQwaMtYq90", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Plague", + incidentStatus: "Watch", + }, + { + id: "UrArvseg6kX", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "SARIs", + }, + { + id: "uXG454CtUma", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "SARIs", + incidentStatus: "Alert", + }, + { + id: "jLXONCZxxfh", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "SARIs", + incidentStatus: "Respond", + }, + { + id: "cQb9Hdw5jql", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "SARIs", + incidentStatus: "Watch", + }, + { + id: "CqH1jf6gGFD", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Typhoid fever", + }, + { + id: "Usju3ALyYYY", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Typhoid fever", + incidentStatus: "Alert", + }, + { + id: "mH1Qofgu1qj", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Typhoid fever", + incidentStatus: "Respond", + }, + { + id: "krtkcFHy5gD", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Typhoid fever", + incidentStatus: "Watch", + }, + { + id: "iBUJyPMvQc0", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Zika fever", + }, + { + id: "PJYXp6lEf33", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Zika fever", + incidentStatus: "Alert", + }, + { + id: "ulx34yw2fhv", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Zika fever", + incidentStatus: "Respond", + }, + { + id: "PMUSuZjMILK", + name: "% of number of alerts that were detected within 7 days of date of emergence", + type: "detection", + disease: "Zika fever", + incidentStatus: "Watch", + }, + { + id: "pAzsfnu4pjN", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + }, + { + id: "gFjSngCJtyc", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "AFP", + }, + { + id: "e7jWFYSjf4G", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + incidentStatus: "Alert", + disease: "AFP", + }, + { + id: "AmiJBU4lLzg", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + incidentStatus: "Respond", + disease: "AFP", + }, + { + id: "Ysu6dNwU8rD", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + incidentStatus: "Watch", + disease: "AFP", + }, + { + id: "ujD1Such0FX", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute Respiratory", + }, + { + id: "rmKCa08KHml", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute Respiratory", + incidentStatus: "Alert", + }, + { + id: "uC4adVvbDmz", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute Respiratory", + incidentStatus: "Respond", + }, + { + id: "XEJCxmOdf8H", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute Respiratory", + incidentStatus: "Watch", + }, + { + id: "siZI9LHQwdp", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute VHF", + }, + { + id: "Dfs66idkbGh", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute VHF", + incidentStatus: "Alert", + }, + { + id: "tfVZ0aXD2nl", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute VHF", + incidentStatus: "Respond", + }, + { + id: "zev7Ksngoex", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Acute VHF", + incidentStatus: "Watch", + }, + { + id: "ViFa8BNG01k", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Anthrax", + }, + { + id: "Ig4wLi4cOsy", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Anthrax", + incidentStatus: "Alert", + }, + { + id: "tDOGR6DjE7D", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Anthrax", + incidentStatus: "Respond", + }, + { + id: "VdxB4vJZ15R", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Anthrax", + incidentStatus: "Watch", + }, + { + id: "a17C88VS6Dy", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Bacterial Meningitis", + }, + { + id: "ZqvCFe2WlAO", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Bacterial Meningitis", + incidentStatus: "Alert", + }, + { + id: "GdO2wxSkV8n", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Bacterial Meningitis", + incidentStatus: "Respond", + }, + { + id: "etcnMIPSSL0", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Bacterial Meningitis", + incidentStatus: "Watch", + }, + { + id: "G2fBbBi6as1", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Cholera", + }, + { + id: "MkEXS8gy8vv", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Cholera", + incidentStatus: "Alert", + }, + { + id: "fHMlOjNZ9S1", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Cholera", + incidentStatus: "Respond", + }, + { + id: "DlDbMhEjoMi", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Cholera", + incidentStatus: "Watch", + }, + { + id: "iQ3thUxI4Bw", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Covid19", + }, + { + id: "cQbg0IJFiko", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Covid19", + incidentStatus: "Alert", + }, + { + id: "BL4G9BPrsWR", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Covid19", + incidentStatus: "Respond", + }, + { + id: "KTZhKNpliQF", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Covid19", + incidentStatus: "Watch", + }, + { + id: "BSmZm0sThuY", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Diarrhea with blood", + }, + { + id: "UpgL6Yu28QQ", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Diarrhea with blood", + incidentStatus: "Alert", + }, + { + id: "jbCO7APEXkx", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Diarrhea with blood", + incidentStatus: "Respond", + }, + { + id: "FRZAF4iK4Ek", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Diarrhea with blood", + incidentStatus: "Watch", + }, + { + id: "AcwSBWB2qlM", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Measles", + }, + { + id: "kxsCnbCHb1b", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Measles", + incidentStatus: "Alert", + }, + { + id: "WIzPozWkU9o", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Measles", + incidentStatus: "Respond", + }, + { + id: "anK7GLeSHF7", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Measles", + incidentStatus: "Watch", + }, + { + id: "AgpPSrHC2pj", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Monkeypox", + }, + { + id: "jT8CcfsJTBi", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Monkeypox", + incidentStatus: "Alert", + }, + { + id: "TLaP8Lu1dhB", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Monkeypox", + incidentStatus: "Respond", + }, + { + id: "ofEzx7UmuDS", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Monkeypox", + incidentStatus: "Watch", + }, + { + id: "sY8hoUE4JoB", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Neonatal tetanus", + }, + { + id: "ZzeMj5kr7wY", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Neonatal tetanus", + incidentStatus: "Alert", + }, + { + id: "ElFKHitzAks", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Neonatal tetanus", + incidentStatus: "Respond", + }, + { + id: "PMS4noi5GN8", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Neonatal tetanus", + incidentStatus: "Watch", + }, + { + id: "NuInWIPC5IT", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Plague", + }, + { + id: "xfR2LhUqkSI", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Plague", + incidentStatus: "Alert", + }, + { + id: "LbHCxDecGTt", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Plague", + incidentStatus: "Respond", + }, + { + id: "J6G42iXjHqN", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Plague", + incidentStatus: "Watch", + }, + { + id: "u8g5YyaZcrI", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "SARIs", + }, + { + id: "ijldyhHh9Yy", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "SARIs", + incidentStatus: "Alert", + }, + { + id: "YCr9TpgA7Lg", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "SARIs", + incidentStatus: "Respond", + }, + { + id: "l5b6LawyANZ", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "SARIs", + incidentStatus: "Watch", + }, + { + id: "WaX2bMKuHGg", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Typhoid fever", + }, + { + id: "LkHrcxooPtt", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Typhoid fever", + incidentStatus: "Alert", + }, + { + id: "ruEiWFop4jQ", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Typhoid fever", + incidentStatus: "Respond", + }, + { + id: "HBFFAFXT4hA", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Typhoid fever", + incidentStatus: "Watch", + }, + { + id: "Wfs9JweVQfv", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Zika fever", + }, + { + id: "AnBWynoByJz", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Zika fever", + incidentStatus: "Alert", + }, + { + id: "LTahquMgu5i", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Zika fever", + incidentStatus: "Respond", + }, + { + id: "ldOsmniN5HQ", + name: "% of number of alerts where Responded date is within 7 days of notification", + type: "response", + disease: "Zika fever", + incidentStatus: "Watch", + }, +]; diff --git a/src/data/repositories/test/ProgramIndicatorsTestRepository.ts b/src/data/repositories/test/ProgramIndicatorsTestRepository.ts index f367eea1..4c98dc55 100644 --- a/src/data/repositories/test/ProgramIndicatorsTestRepository.ts +++ b/src/data/repositories/test/ProgramIndicatorsTestRepository.ts @@ -6,6 +6,9 @@ export class ProgramIndicatorsTestRepository implements AnalyticsRepository { getDiseasesTotal(): FutureData { return Future.success(0); } + get717Performance(): FutureData { + return Future.success(0); + } getProgramIndicators(): FutureData { return Future.success([ { diff --git a/src/domain/repositories/AnalyticsRepository.ts b/src/domain/repositories/AnalyticsRepository.ts index 644de089..53408229 100644 --- a/src/domain/repositories/AnalyticsRepository.ts +++ b/src/domain/repositories/AnalyticsRepository.ts @@ -1,5 +1,8 @@ import { FutureData } from "../../data/api-futures"; -import { ProgramIndicatorBaseAttrs } from "../../data/repositories/AnalyticsD2Repository"; +import { + Indicator717PerformanceBaseAttrs, + ProgramIndicatorBaseAttrs, +} from "../../data/repositories/AnalyticsD2Repository"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; export interface AnalyticsRepository { @@ -7,4 +10,7 @@ export interface AnalyticsRepository { diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] ): FutureData; getDiseasesTotal(filters?: Record): FutureData; + get717Performance( + filters?: Record + ): FutureData; } diff --git a/src/domain/usecases/Get717PerformanceUseCase.ts b/src/domain/usecases/Get717PerformanceUseCase.ts new file mode 100644 index 00000000..de5f4400 --- /dev/null +++ b/src/domain/usecases/Get717PerformanceUseCase.ts @@ -0,0 +1,25 @@ +import { FutureData } from "../../data/api-futures"; +import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { OptionsRepository } from "../repositories/OptionsRepository"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; +import { AnalyticsRepository } from "../repositories/AnalyticsRepository"; +import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; +import { Indicator717PerformanceBaseAttrs } from "../../data/repositories/AnalyticsD2Repository"; + +export class Get717PerformanceUseCase { + constructor( + private options: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + optionsRepository: OptionsRepository; + teamMemberRepository: TeamMemberRepository; + orgUnitRepository: OrgUnitRepository; + analytics: AnalyticsRepository; + } + ) {} + + public execute( + filters?: Record + ): FutureData { + return this.options.analytics.get717Performance(filters); + } +} diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 5a59a0f0..ba94d28b 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -1,12 +1,11 @@ import React from "react"; - import i18n from "../../../utils/i18n"; import { Layout } from "../../components/layout/Layout"; import { Section } from "../../components/section/Section"; import { StatisticTable } from "../../components/table/statistic-table/StatisticTable"; import { usePerformanceOverview } from "./usePerformanceOverview"; import { useDiseasesTotal } from "./useDiseasesTotal"; -import { StatsCard, StatsCardProps } from "../../components/stats-card/StatsCard"; +import { StatsCard } from "../../components/stats-card/StatsCard"; import styled from "styled-components"; import { MultipleSelector } from "../../components/selector/MultipleSelector"; import { Id } from "@eyeseetea/d2-api"; @@ -14,6 +13,7 @@ import { Maybe } from "../../../utils/ts-utils"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useFilters } from "./useFilters"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; +import { PerformanceIndicator717, use717Performance } from "./use717Performance"; export const DashboardPage: React.FC = React.memo(() => { const { filters, filterOptions, setFilters } = useFilters(); @@ -28,7 +28,7 @@ export const DashboardPage: React.FC = React.memo(() => { } = usePerformanceOverview(); const { diseasesTotal } = useDiseasesTotal(filters); - + const { performanceIndicators } = use717Performance(filters); // Add default value as an empty array const { goTo } = useRoutes(); const goToEvent = (id: Maybe) => { @@ -36,32 +36,6 @@ export const DashboardPage: React.FC = React.memo(() => { goTo(RouteName.EVENT_TRACKER, { id }); }; - const performances: StatsCardProps[] = [ - { - title: "Detection", - stat: "57", - pretitle: "4 events", - color: "green", - }, - { - title: "Notification", - stat: "43", - pretitle: "3 events", - color: "red", - }, - { - title: "Response", - stat: "57", - pretitle: "4 events", - color: "green", - }, - { - title: "All targets", - stat: "14", - pretitle: "1 events", - color: "grey", - }, - ]; return (
@@ -103,18 +77,22 @@ export const DashboardPage: React.FC = React.memo(() => {
- {performances && - performances.map((per, index) => ( + {performanceIndicators.map( + ( + per: PerformanceIndicator717["performanceIndicators"][0], + index: number + ) => ( - ))} + ) + )}
diff --git a/src/webapp/pages/dashboard/use717Performance.ts b/src/webapp/pages/dashboard/use717Performance.ts new file mode 100644 index 00000000..8902de7e --- /dev/null +++ b/src/webapp/pages/dashboard/use717Performance.ts @@ -0,0 +1,83 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import _ from "../../../domain/entities/generic/Collection"; +import { StatsCardProps } from "../../components/stats-card/StatsCard"; +import { Indicator717PerformanceBaseAttrs } from "../../../data/repositories/AnalyticsD2Repository"; + +export type PerformanceIndicator717 = { + performanceIndicators: { + title: string; + percent: number; + count: number; + color: StatsCardProps["color"]; + }[]; + isLoading: boolean; +}; + +export type Order = { name: string; direction: "asc" | "desc" }; + +export function use717Performance(filters: Record): PerformanceIndicator717 { + const { compositionRoot } = useAppContext(); + + const [performanceIndicators, setPerformanceIndicators] = useState< + PerformanceIndicator717["performanceIndicators"] + >([]); + const [isLoading, setIsLoading] = useState(false); + const transformData = (performanceIndicators: Indicator717PerformanceBaseAttrs[]) => { + const performanceIndicatorsByName = _(performanceIndicators).reduce( + (acc: Record, indicator) => { + const key = indicator.name; // Grouping by 'name' property + const existingGroup = acc[key] || []; // Get the existing group or an empty array if it doesn't exist + acc[key] = [...existingGroup, indicator]; // Create a new HashMap with the updated group + return acc; + }, + {} as Record + ); + return Object.entries(performanceIndicatorsByName).map(([key, values]) => { + const percentObj = values.find(item => item.type === "percent"); + const countObj = values.find(item => item.type === "count"); + + const percent = percentObj ? percentObj.value : 0; + const count = countObj ? countObj.value : 0; + + let color: "green" | "red" | "grey" | "normal" | undefined; + if (percent >= 50) { + color = "green"; + } else if (percent > 0) { + color = "red"; + } else { + color = "grey"; + } + + const title = key + .replace(/([A-Z])/g, match => ` ${match}`) + .replace(/^./, match => match.toUpperCase()) + .trim(); + return { + title: title, + percent: percent, + count: count, + color: color, + }; + }); + }; + + useEffect(() => { + setIsLoading(true); + compositionRoot.analytics.get717Performance.execute(filters).run( + performanceIndicators => { + setPerformanceIndicators(transformData(performanceIndicators)); + setIsLoading(false); + }, + error => { + console.error({ error }); + setIsLoading(false); + } + ); + }, [compositionRoot.analytics.get717Performance, filters]); + + return { + performanceIndicators, + isLoading, + }; +} From 2f309d6509512b04ac7b6679341097f18cfa54cb Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Mon, 16 Sep 2024 15:55:04 +0200 Subject: [PATCH 02/24] update localize --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index ca95e5f7..1415e5eb 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-12T14:10:04.460Z\n" -"PO-Revision-Date: 2024-09-12T14:10:04.460Z\n" +"POT-Creation-Date: 2024-09-16T13:53:55.684Z\n" +"PO-Revision-Date: 2024-09-16T13:53:55.684Z\n" msgid "Low" msgstr "" @@ -111,6 +111,9 @@ msgstr "" msgid "7-1-7 performance" msgstr "" +msgid "events" +msgstr "" + msgid "Performance overview" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 649b1659..c368397e 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T14:10:04.460Z\n" +"POT-Creation-Date: 2024-09-16T13:53:55.684Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -110,6 +110,9 @@ msgstr "" msgid "7-1-7 performance" msgstr "" +msgid "events" +msgstr "" + msgid "Performance overview" msgstr "" From f2272d08f59111740a24e480fc312fc6fea8c358 Mon Sep 17 00:00:00 2001 From: fdelemarre Date: Wed, 18 Sep 2024 10:39:45 +0200 Subject: [PATCH 03/24] remove comment and prepare filtering --- src/data/repositories/consts/AnalyticsConstants.ts | 3 ++- src/webapp/pages/dashboard/DashboardPage.tsx | 2 +- src/webapp/pages/dashboard/use717Performance.ts | 12 ++++++------ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/data/repositories/consts/AnalyticsConstants.ts b/src/data/repositories/consts/AnalyticsConstants.ts index 708c8a9f..c5d97bc7 100644 --- a/src/data/repositories/consts/AnalyticsConstants.ts +++ b/src/data/repositories/consts/AnalyticsConstants.ts @@ -234,7 +234,8 @@ export const INDICATORS_717_PERFORMANCE = [ { id: "FbPj6kekXBj", name: "allTargets", type: "count" }, ]; -const INDICATORS_717 = [ +// TODO To be updated with allTargets and event count +export const INDICATORS_717_PERFORMANCE_WIP = [ { id: "SnlZWWmSnev", name: "% of number of alerts notified to public health authorities within 1 day of detection", diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 6b5543b3..4da9280b 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -28,7 +28,7 @@ export const DashboardPage: React.FC = React.memo(() => { } = usePerformanceOverview(); const { diseasesTotal } = useDiseasesTotal(filters); - const { performanceIndicators } = use717Performance(filters); // Add default value as an empty array + const { performanceIndicators } = use717Performance(filters); const { goTo } = useRoutes(); const goToEvent = (id: Maybe) => { diff --git a/src/webapp/pages/dashboard/use717Performance.ts b/src/webapp/pages/dashboard/use717Performance.ts index 8902de7e..120cb517 100644 --- a/src/webapp/pages/dashboard/use717Performance.ts +++ b/src/webapp/pages/dashboard/use717Performance.ts @@ -26,9 +26,9 @@ export function use717Performance(filters: Record): Performanc const transformData = (performanceIndicators: Indicator717PerformanceBaseAttrs[]) => { const performanceIndicatorsByName = _(performanceIndicators).reduce( (acc: Record, indicator) => { - const key = indicator.name; // Grouping by 'name' property - const existingGroup = acc[key] || []; // Get the existing group or an empty array if it doesn't exist - acc[key] = [...existingGroup, indicator]; // Create a new HashMap with the updated group + const key = indicator.name; + const existingGroup = acc[key] || []; + acc[key] = [...existingGroup, indicator]; return acc; }, {} as Record @@ -41,12 +41,12 @@ export function use717Performance(filters: Record): Performanc const count = countObj ? countObj.value : 0; let color: "green" | "red" | "grey" | "normal" | undefined; - if (percent >= 50) { + if (key === "allTargets") { + color = "grey"; + } else if (percent >= 50) { color = "green"; } else if (percent > 0) { color = "red"; - } else { - color = "grey"; } const title = key From 17db20ccdaed7f6aaa7ddd2f86e3e09bec0c9f46 Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Thu, 3 Oct 2024 22:02:32 +0530 Subject: [PATCH 04/24] fix: some refactoring --- .../PerformanceOverviewD2Repository.ts | 100 ++++++++---------- .../consts/PerformanceOverviewConstants.ts | 3 +- .../PerformanceOverviewMetrics.ts | 7 ++ .../PerformanceOverviewRepository.ts | 4 +- .../usecases/Get717PerformanceUseCase.ts | 4 +- src/webapp/pages/dashboard/DashboardPage.tsx | 2 +- .../pages/dashboard/use717Performance.ts | 87 ++++++++------- 7 files changed, 104 insertions(+), 103 deletions(-) diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index cf0869e8..d4b9ccd3 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -7,7 +7,7 @@ import _ from "../../domain/entities/generic/Collection"; import { Future } from "../../domain/entities/generic/Future"; import { eventTrackerCountsIndicatorMap, - INDICATORS_717_PERFORMANCE, + PERFORMANCE_METRICS_717_IDS, IndicatorsId, } from "./consts/PerformanceOverviewConstants"; import moment from "moment"; @@ -21,17 +21,11 @@ import { HazardNames, PerformanceOverviewMetrics, DiseaseNames, + PerformanceMetrics717, } from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; import { OrgUnit } from "../../domain/entities/OrgUnit"; -export type Indicator717PerformanceBaseAttrs = { - id: string; - name: string; - type: "count" | "percent"; - value: number; -}; - const formatDate = (date: Date): string => { const year = date.getFullYear(); const month = ("0" + (date.getMonth() + 1)).slice(-2); @@ -40,7 +34,6 @@ const formatDate = (date: Date): string => { }; const DEFAULT_END_DATE: string = formatDate(new Date()); - const DEFAULT_START_DATE = "2000-01-01"; export class PerformanceOverviewD2Repository implements PerformanceOverviewRepository { @@ -151,27 +144,26 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos 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, - ], - } - ) + 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 => { const mappedIndicators = indicatorsProgramFuture?.rows.map((row: string[]) => @@ -216,43 +208,37 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos }); } - get717Performance(): FutureData { + get717Performance(): FutureData { const transformData = ( - data: string[][], - indicators717Performance: typeof INDICATORS_717_PERFORMANCE - ): Indicator717PerformanceBaseAttrs[] => { - return data.flatMap(([id, , value]) => { - const indicator = indicators717Performance.find(d => d.id === id); - if (!indicator || !value) { - return []; - } - - // Ensure the type is either 'count' or 'percent' - const type: "count" | "percent" = - indicator.type === "count" || indicator.type === "percent" - ? indicator.type - : "count"; // Default to 'count' if type is not valid - - return [ - { + performanceMetric717Response: string[][], + indicators717Performance: PerformanceMetrics717[] + ): PerformanceMetrics717[] => { + return _( + performanceMetric717Response.map(([id, value]) => { + const indicator = indicators717Performance.find(d => d.id === id); + if (!indicator || !value) { + return undefined; + } + return { ...indicator, value: parseFloat(value), - type, // Set the valid type here with narrowed types - }, - ]; - }); + type: indicator.type, + }; + }) + ) + .compact() + .value(); }; return apiToFuture( this.api.analytics.get({ - dimension: [ - `dx:${INDICATORS_717_PERFORMANCE.map(({ id }) => id).join(";")}`, - "pe:THIS_YEAR", - ], + dimension: [`dx:${PERFORMANCE_METRICS_717_IDS.map(({ id }) => id).join(";")}`], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, includeMetadataDetails: true, }) ).map(res => { - return transformData(res.rows, INDICATORS_717_PERFORMANCE) || []; + return transformData(res.rows, PERFORMANCE_METRICS_717_IDS) || []; }); } diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index f04a86b7..34a89d63 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -2,6 +2,7 @@ import { DiseaseNames, HazardNames, IncidentStatus, + PerformanceMetrics717, } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { Id } from "../../../domain/entities/Ref"; @@ -265,7 +266,7 @@ export const eventTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ { id: "gRcZNqpKyYg", type: "hazard", name: "Environmental", incidentStatus: "Respond" }, ]; -export const INDICATORS_717_PERFORMANCE = [ +export const PERFORMANCE_METRICS_717_IDS: PerformanceMetrics717[] = [ { id: "VWazDAQ15Uw", name: "detection", type: "percent" }, // % of number of alerts that were detected within 7 days of date of emergence { id: "jnJHR2D0cN3", name: "detection", type: "count" }, // Number of alerts notified to public health authorities within 1 day of detection diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts index 58e72586..b7979539 100644 --- a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -67,3 +67,10 @@ type HazardCounts = BaseCounts & { }; export type TotalCardCounts = DiseaseCounts | HazardCounts; + +export type PerformanceMetrics717 = { + id: string; + name: string; + type: "count" | "percent"; + value?: number; +}; diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index 1372bde4..a649f7e7 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -1,9 +1,9 @@ import { FutureData } from "../../data/api-futures"; -import { Indicator717PerformanceBaseAttrs } from "../../data/repositories/PerformanceOverviewD2Repository"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { TotalCardCounts, PerformanceOverviewMetrics, + PerformanceMetrics717, } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; export interface PerformanceOverviewRepository { @@ -16,5 +16,5 @@ export interface PerformanceOverviewRepository { multiSelectFilters?: Record, dateRangeFilter?: string[] ): FutureData; - get717Performance(): FutureData; + get717Performance(): FutureData; } diff --git a/src/domain/usecases/Get717PerformanceUseCase.ts b/src/domain/usecases/Get717PerformanceUseCase.ts index 8cd15fa5..ea224044 100644 --- a/src/domain/usecases/Get717PerformanceUseCase.ts +++ b/src/domain/usecases/Get717PerformanceUseCase.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../data/api-futures"; -import { Indicator717PerformanceBaseAttrs } from "../../data/repositories/PerformanceOverviewD2Repository"; +import { PerformanceMetrics717 } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { PerformanceOverviewRepository } from "../repositories/PerformanceOverviewRepository"; export class Get717PerformanceUseCase { @@ -9,7 +9,7 @@ export class Get717PerformanceUseCase { } ) {} - public execute(): FutureData { + public execute(): FutureData { return this.options.performanceOverviewRepository.get717Performance(); } } diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index e4511281..1c0977fb 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -39,7 +39,7 @@ export const DashboardPage: React.FC = React.memo(() => { editRiskAssessmentColumns, } = usePerformanceOverview(); - const { performanceIndicators } = use717Performance(multiSelectFilters); + const { performanceIndicators } = use717Performance(); const { cardCounts } = useCardCounts( singleSelectFilters, multiSelectFilters, diff --git a/src/webapp/pages/dashboard/use717Performance.ts b/src/webapp/pages/dashboard/use717Performance.ts index e2139273..f2ca52f3 100644 --- a/src/webapp/pages/dashboard/use717Performance.ts +++ b/src/webapp/pages/dashboard/use717Performance.ts @@ -1,66 +1,73 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import _ from "../../../domain/entities/generic/Collection"; import { StatsCardProps } from "../../components/stats-card/StatsCard"; -import { Indicator717PerformanceBaseAttrs } from "../../../data/repositories/PerformanceOverviewD2Repository"; +import { PerformanceMetrics717 } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +type CardColors = StatsCardProps["color"]; export type PerformanceIndicator717 = { performanceIndicators: { title: string; percent: number; count: number; - color: StatsCardProps["color"]; + color: CardColors; }[]; isLoading: boolean; }; export type Order = { name: string; direction: "asc" | "desc" }; -export function use717Performance(filters: Record): PerformanceIndicator717 { +export function use717Performance(): PerformanceIndicator717 { const { compositionRoot } = useAppContext(); const [performanceIndicators, setPerformanceIndicators] = useState< PerformanceIndicator717["performanceIndicators"] >([]); const [isLoading, setIsLoading] = useState(false); - const transformData = (performanceIndicators: Indicator717PerformanceBaseAttrs[]) => { - const performanceIndicatorsByName = _(performanceIndicators).reduce( - (acc: Record, indicator) => { - const key = indicator.name; - const existingGroup = acc[key] || []; - acc[key] = [...existingGroup, indicator]; - return acc; - }, - {} as Record - ); - return Object.entries(performanceIndicatorsByName).map(([key, values]) => { - const percentObj = values.find(item => item.type === "percent"); - const countObj = values.find(item => item.type === "count"); - const percent = percentObj ? percentObj.value : 0; - const count = countObj ? countObj.value : 0; + const getColor = useCallback((key: string, percent: number): CardColors => { + if (key === "allTargets") { + return "grey"; + } else if (percent >= 50) { + return "green"; + } else if (percent > 0) { + return "red"; + } + return "normal"; + }, []); - let color: "green" | "red" | "grey" | "normal" | undefined; - if (key === "allTargets") { - color = "grey"; - } else if (percent >= 50) { - color = "green"; - } else if (percent > 0) { - color = "red"; - } + const transformData = useCallback( + (performanceIndicators: PerformanceMetrics717[]) => { + const performanceIndicatorsByName = _(performanceIndicators).reduce( + (acc: Record, indicator) => { + const key = indicator.name; + const existingGroup = acc[key] || []; + acc[key] = [...existingGroup, indicator]; + return acc; + }, + {} as Record + ); + return Object.entries(performanceIndicatorsByName).map(([key, values]) => { + const percentObj = values.find(item => item.type === "percent"); + const countObj = values.find(item => item.type === "count"); - const title = key - .replace(/([A-Z])/g, match => ` ${match}`) - .replace(/^./, match => match.toUpperCase()) - .trim(); - return { - title: title, - percent: percent, - count: count, - color: color, - }; - }); - }; + const percent = percentObj?.value ?? 0; + const count = countObj?.value ?? 0; + + const title = key + .replace(/([A-Z])/g, match => ` ${match}`) + .replace(/^./, match => match.toUpperCase()) + .trim(); + return { + title: title, + percent: percent, + count: count, + color: getColor(key, percent), + }; + }); + }, + [getColor] + ); useEffect(() => { setIsLoading(true); @@ -74,7 +81,7 @@ export function use717Performance(filters: Record): Performanc setIsLoading(false); } ); - }, [compositionRoot.performanceOverview.get717Performance, filters]); + }, [compositionRoot.performanceOverview.get717Performance, transformData]); return { performanceIndicators, From f779051363d0a8d0e8cc085fc0c7dbcfbec12fa2 Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Sat, 5 Oct 2024 22:17:04 +0530 Subject: [PATCH 05/24] fix: some renaming + loader for dashboard page --- .../PerformanceOverviewD2Repository.ts | 46 ++++++++++--------- .../DiseaseOutbreakEventWithOptions.ts | 27 ----------- src/webapp/pages/dashboard/DashboardPage.tsx | 39 ++++++++-------- .../pages/dashboard/use717Performance.ts | 28 +++++------ 4 files changed, 57 insertions(+), 83 deletions(-) delete mode 100644 src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index d4b9ccd3..479d4031 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -208,28 +208,30 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos }); } - get717Performance(): FutureData { - const transformData = ( - performanceMetric717Response: string[][], - indicators717Performance: PerformanceMetrics717[] - ): PerformanceMetrics717[] => { - return _( - performanceMetric717Response.map(([id, value]) => { - const indicator = indicators717Performance.find(d => d.id === id); - if (!indicator || !value) { - return undefined; - } - return { - ...indicator, - value: parseFloat(value), - type: indicator.type, - }; - }) - ) - .compact() - .value(); - }; + private mapIndicatorsTo717PerformanceMetrics( + performanceMetric717Response: string[][] + ): PerformanceMetrics717[] { + return _( + performanceMetric717Response.map(([id, value]) => { + const indicator = PERFORMANCE_METRICS_717_IDS.find(d => d.id === id); + + if (!indicator) throw new Error(`Unknown Indicator with id ${id} `); + + if (!value) { + return undefined; + } + return { + ...indicator, + value: parseFloat(value), + type: indicator.type, + }; + }) + ) + .compact() + .value(); + } + get717Performance(): FutureData { return apiToFuture( this.api.analytics.get({ dimension: [`dx:${PERFORMANCE_METRICS_717_IDS.map(({ id }) => id).join(";")}`], @@ -238,7 +240,7 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos includeMetadataDetails: true, }) ).map(res => { - return transformData(res.rows, PERFORMANCE_METRICS_717_IDS) || []; + return this.mapIndicatorsTo717PerformanceMetrics(res.rows); }); } diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts deleted file mode 100644 index 8409e199..00000000 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEventWithOptions.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Maybe } from "../../../utils/ts-utils"; -import { TeamMember } from "../incident-management-team/TeamMember"; -import { Option } from "../Ref"; -import { Rule } from "../Rule"; -import { ValidationErrorKey } from "../ValidationError"; -import { DiseaseOutbreakEventBaseAttrs } from "./DiseaseOutbreakEvent"; - -export type DiseaseOutbreakEventOptions = { - dataSources: Option[]; - hazardTypes: Option[]; - mainSyndromes: Option[]; - suspectedDiseases: Option[]; - notificationSources: Option[]; - incidentStatus: Option[]; - incidentManagers: TeamMember[]; -}; - -export type DiseaseOutbreakEventLabels = { - errors: Record; -}; - -export type DiseaseOutbreakEventWithOptions = { - diseaseOutbreakEvent: Maybe; - options: DiseaseOutbreakEventOptions; - labels: DiseaseOutbreakEventLabels; - rules: Rule[]; -}; diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 1c0977fb..a84b273c 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -17,7 +17,8 @@ import { useAlertsActiveVerifiedFilters } from "./useAlertsActiveVerifiedFilters import { MapSection } from "../../components/map/MapSection"; import { Selector } from "../../components/selector/Selector"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; -import { PerformanceIndicator717, use717Performance } from "./use717Performance"; +import { PerformanceMetric717, use717Performance } from "./use717Performance"; +import { Loader } from "../../components/loader/Loader"; export const DashboardPage: React.FC = React.memo(() => { const { @@ -37,10 +38,11 @@ export const DashboardPage: React.FC = React.memo(() => { setOrder, columnRules, editRiskAssessmentColumns, + isLoading: performanceOverviewLoading, } = usePerformanceOverview(); - const { performanceIndicators } = use717Performance(); - const { cardCounts } = useCardCounts( + const { performanceMetrics717, isLoading: _717CardsLoading } = use717Performance(); + const { cardCounts, isLoading: cardCountsLoading } = useCardCounts( singleSelectFilters, multiSelectFilters, dateRangeFilter.value @@ -59,7 +61,9 @@ export const DashboardPage: React.FC = React.memo(() => { goTo(RouteName.EVENT_TRACKER, { id }); }; - return ( + return performanceOverviewLoading || _717CardsLoading || cardCountsLoading ? ( + + ) : (
@@ -123,22 +127,17 @@ export const DashboardPage: React.FC = React.memo(() => {
- {performanceIndicators.map( - ( - per: PerformanceIndicator717["performanceIndicators"][0], - index: number - ) => ( - - ) - )} + {performanceMetrics717.map((per: PerformanceMetric717, index: number) => ( + + ))}
diff --git a/src/webapp/pages/dashboard/use717Performance.ts b/src/webapp/pages/dashboard/use717Performance.ts index f2ca52f3..f3d3ac91 100644 --- a/src/webapp/pages/dashboard/use717Performance.ts +++ b/src/webapp/pages/dashboard/use717Performance.ts @@ -5,24 +5,24 @@ import { StatsCardProps } from "../../components/stats-card/StatsCard"; import { PerformanceMetrics717 } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; type CardColors = StatsCardProps["color"]; -export type PerformanceIndicator717 = { - performanceIndicators: { - title: string; - percent: number; - count: number; - color: CardColors; - }[]; + +export type PerformanceMetric717 = { + title: string; + percent: number; + count: number; + color: CardColors; +}; +export type PerformanceMetric717State = { + performanceMetrics717: PerformanceMetric717[]; isLoading: boolean; }; export type Order = { name: string; direction: "asc" | "desc" }; -export function use717Performance(): PerformanceIndicator717 { +export function use717Performance(): PerformanceMetric717State { const { compositionRoot } = useAppContext(); - const [performanceIndicators, setPerformanceIndicators] = useState< - PerformanceIndicator717["performanceIndicators"] - >([]); + const [performanceMetrics717, setPerformanceMetrics717] = useState([]); const [isLoading, setIsLoading] = useState(false); const getColor = useCallback((key: string, percent: number): CardColors => { @@ -72,8 +72,8 @@ export function use717Performance(): PerformanceIndicator717 { useEffect(() => { setIsLoading(true); compositionRoot.performanceOverview.get717Performance.execute().run( - performanceIndicators => { - setPerformanceIndicators(transformData(performanceIndicators)); + performanceMetrics717 => { + setPerformanceMetrics717(transformData(performanceMetrics717)); setIsLoading(false); }, error => { @@ -84,7 +84,7 @@ export function use717Performance(): PerformanceIndicator717 { }, [compositionRoot.performanceOverview.get717Performance, transformData]); return { - performanceIndicators, + performanceMetrics717, isLoading, }; } From 642773bf72555b492757b22600e14583c9b553cd Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Sun, 6 Oct 2024 23:42:35 +0530 Subject: [PATCH 06/24] feat: cases, deaths and risk history charts --- src/CompositionRoot.ts | 10 ++ .../repositories/ChartConfigD2Repository.ts | 53 ++++++ .../test/ChartConfigTestRepository.ts | 15 ++ .../repositories/ChartConfigRepository.ts | 7 + .../usecases/GetChartConfigByTypeUseCase.ts | 19 +++ src/webapp/components/chart/Chart.tsx | 40 +++++ src/webapp/components/chart/useChart.ts | 25 +++ src/webapp/components/map/Map.tsx | 140 ---------------- src/webapp/components/map/MapSection.tsx | 45 ++++- .../visualisation/Visualisation.tsx | 158 +++++++++++++++--- .../pages/event-tracker/EventTrackerPage.tsx | 77 +++++---- 11 files changed, 383 insertions(+), 206 deletions(-) create mode 100644 src/data/repositories/ChartConfigD2Repository.ts create mode 100644 src/data/repositories/test/ChartConfigTestRepository.ts create mode 100644 src/domain/repositories/ChartConfigRepository.ts create mode 100644 src/domain/usecases/GetChartConfigByTypeUseCase.ts create mode 100644 src/webapp/components/chart/Chart.tsx create mode 100644 src/webapp/components/chart/useChart.ts delete mode 100644 src/webapp/components/map/Map.tsx diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 0d6325e5..006d3611 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -42,6 +42,10 @@ import { AlertSyncDataStoreTestRepository } from "./data/repositories/test/Alert import { AlertSyncRepository } from "./domain/repositories/AlertSyncRepository"; import { DataStoreClient } from "./data/DataStoreClient"; import { GetTotalCardCountsUseCase } from "./domain/usecases/GetTotalCardCountsUseCase"; +import { ChartConfigRepository } from "./domain/repositories/ChartConfigRepository"; +import { GetChartConfigByTypeUseCase } from "./domain/usecases/GetChartConfigByTypeUseCase"; +import { ChartConfigTestRepository } from "./data/repositories/test/ChartConfigTestRepository"; +import { ChartConfigD2Repository } from "./data/repositories/ChartConfigD2Repository"; export type CompositionRoot = ReturnType; @@ -56,6 +60,7 @@ type Repositories = { riskAssessmentRepository: RiskAssessmentRepository; mapConfigRepository: MapConfigRepository; performanceOverviewRepository: PerformanceOverviewRepository; + chartConfigRepository: ChartConfigRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -91,6 +96,9 @@ function getCompositionRoot(repositories: Repositories) { getAll: new GetAllOrgUnitsUseCase(repositories.orgUnitRepository), getProvinces: new GetProvincesOrgUnits(repositories.orgUnitRepository), }, + charts: { + getCases: new GetChartConfigByTypeUseCase(repositories.chartConfigRepository), + }, }; } @@ -107,6 +115,7 @@ export function getWebappCompositionRoot(api: D2Api) { riskAssessmentRepository: new RiskAssessmentD2Repository(api), mapConfigRepository: new MapConfigD2Repository(api), performanceOverviewRepository: new PerformanceOverviewD2Repository(api, dataStoreClient), + chartConfigRepository: new ChartConfigD2Repository(dataStoreClient), }; return getCompositionRoot(repositories); @@ -124,6 +133,7 @@ export function getTestCompositionRoot() { riskAssessmentRepository: new RiskAssessmentTestRepository(), mapConfigRepository: new MapConfigTestRepository(), performanceOverviewRepository: new PerformanceOverviewTestRepository(), + chartConfigRepository: new ChartConfigTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/ChartConfigD2Repository.ts b/src/data/repositories/ChartConfigD2Repository.ts new file mode 100644 index 00000000..17ac2418 --- /dev/null +++ b/src/data/repositories/ChartConfigD2Repository.ts @@ -0,0 +1,53 @@ +import { DataStoreClient } from "../DataStoreClient"; +import { FutureData } from "../api-futures"; +import { ChartConfigRepository } from "../../domain/repositories/ChartConfigRepository"; +import { Id } from "../../domain/entities/Ref"; + +type ChartConfig = { + key: string; + casesId: Id; + deathsId: Id; + riskAssessmentHistoryId: Id; +}; + +const chartConfigDatastoreKey = "charts-config"; + +export class ChartConfigD2Repository implements ChartConfigRepository { + constructor(private dataStoreClient: DataStoreClient) {} + + public getCases(chartKey: string): FutureData { + return this.dataStoreClient + .getObject(chartConfigDatastoreKey) + .map(chartConfigs => { + const currentChart = chartConfigs?.find( + chartConfig => chartConfig.key === chartKey + ); + if (currentChart) return currentChart.casesId; + else throw new Error(`Chart id not found for ${chartKey}`); + }); + } + + public getDeaths(chartKey: string): FutureData { + return this.dataStoreClient + .getObject(chartConfigDatastoreKey) + .map(chartConfigs => { + const currentChart = chartConfigs?.find( + chartConfig => chartConfig.key === chartKey + ); + if (currentChart) return currentChart.deathsId; + else throw new Error(`Chart id not found for ${chartKey}`); + }); + } + + public getRiskAssessmentHistory(chartKey: string): FutureData { + return this.dataStoreClient + .getObject(chartConfigDatastoreKey) + .map(chartConfigs => { + const currentChart = chartConfigs?.find( + chartConfig => chartConfig.key === chartKey + ); + if (currentChart) return currentChart.riskAssessmentHistoryId; + else throw new Error(`Chart id not found for ${chartKey}`); + }); + } +} diff --git a/src/data/repositories/test/ChartConfigTestRepository.ts b/src/data/repositories/test/ChartConfigTestRepository.ts new file mode 100644 index 00000000..8942a9bd --- /dev/null +++ b/src/data/repositories/test/ChartConfigTestRepository.ts @@ -0,0 +1,15 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { ChartConfigRepository } from "../../../domain/repositories/ChartConfigRepository"; +import { FutureData } from "../../api-futures"; + +export class ChartConfigTestRepository implements ChartConfigRepository { + getRiskAssessmentHistory(_chartKey: string): FutureData { + return Future.success("1"); + } + getCases(_chartkey: string): FutureData { + return Future.success("1"); + } + getDeaths(_chartKey: string): FutureData { + return Future.success("1"); + } +} diff --git a/src/domain/repositories/ChartConfigRepository.ts b/src/domain/repositories/ChartConfigRepository.ts new file mode 100644 index 00000000..5f61084a --- /dev/null +++ b/src/domain/repositories/ChartConfigRepository.ts @@ -0,0 +1,7 @@ +import { FutureData } from "../../data/api-futures"; + +export interface ChartConfigRepository { + getCases(chartkey: string): FutureData; + getDeaths(chartKey: string): FutureData; + getRiskAssessmentHistory(chartKey: string): FutureData; +} diff --git a/src/domain/usecases/GetChartConfigByTypeUseCase.ts b/src/domain/usecases/GetChartConfigByTypeUseCase.ts new file mode 100644 index 00000000..0527ccbd --- /dev/null +++ b/src/domain/usecases/GetChartConfigByTypeUseCase.ts @@ -0,0 +1,19 @@ +import { FutureData } from "../../data/api-futures"; +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); + } else if (chartType === "risk-assessment-history") { + return this.chartConfigRepository.getRiskAssessmentHistory(chartKey); + } else { + throw new Error(`Invalid chart type: ${chartType}`); + } + } +} diff --git a/src/webapp/components/chart/Chart.tsx b/src/webapp/components/chart/Chart.tsx new file mode 100644 index 00000000..7a8e9580 --- /dev/null +++ b/src/webapp/components/chart/Chart.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { Section } from "../section/Section"; +import { Visualisation } from "../visualisation/Visualisation"; +import { useAppContext } from "../../contexts/app-context"; +import { useChart } from "./useChart"; +import { Maybe } from "../../../utils/ts-utils"; +import LoaderContainer from "../loader/LoaderContainer"; +import { ChartType } from "../../../domain/usecases/GetChartConfigByTypeUseCase"; + +type ChartProps = { + title: string; + chartType: ChartType; + chartKey: Maybe; + hasSeparator?: boolean; + lastUpdated?: string; +}; +export const Chart: React.FC = React.memo(props => { + const { api } = useAppContext(); + const { title, hasSeparator, lastUpdated, chartType, chartKey } = props; + + const { id } = useChart(chartType, chartKey); + + const chartUrl = + chartType === "risk-assessment-history" + ? `${api.baseUrl}/dhis-web-event-visualizer/?id=${id}` + : `${api.baseUrl}/dhis-web-data-visualizer/#/${id}`; + + return ( + +
+ +
+
+ ); +}); diff --git a/src/webapp/components/chart/useChart.ts b/src/webapp/components/chart/useChart.ts new file mode 100644 index 00000000..f803749d --- /dev/null +++ b/src/webapp/components/chart/useChart.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import { Maybe } from "../../../utils/ts-utils"; +import { ChartType } from "../../../domain/usecases/GetChartConfigByTypeUseCase"; + +export function useChart(chartType: ChartType, chartKey: Maybe) { + const { compositionRoot } = useAppContext(); + const [id, setId] = useState(); + + useEffect(() => { + if (!chartKey) { + return; + } + compositionRoot.charts.getCases.execute(chartType, chartKey).run( + chartId => { + setId(chartId); + }, + error => { + console.error(error); + } + ); + }, [chartKey, chartType, compositionRoot.charts.getCases]); + + return { id }; +} diff --git a/src/webapp/components/map/Map.tsx b/src/webapp/components/map/Map.tsx deleted file mode 100644 index 6f3e16fe..00000000 --- a/src/webapp/components/map/Map.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React from "react"; -import styled from "styled-components"; -import { useAppContext } from "../../contexts/app-context"; -import { FilteredMapConfig } from "./useMap"; -import LoaderContainer from "../loader/LoaderContainer"; - -type MapProps = { - config: FilteredMapConfig; -}; - -type State = { - type: "loading" | "loaded"; -}; - -export const Map: React.FC = React.memo(props => { - const { config } = props; - const { api } = useAppContext(); - - const [state, setState] = React.useState({ type: "loading" }); - - const baseUrl = `${api.baseUrl}/api/apps/zebra-custom-maps-app/index.html`; - - const params = { - currentApp: config.currentApp, - currentPage: config.currentPage, - zebraNamespace: config.zebraNamespace, - dashboardDatastoreKey: config.dashboardDatastoreKey, - id: config.mapId, - orgUnits: config.orgUnits.join(","), - programIndicatorId: config.programIndicatorId, - programIndicatorName: config.programIndicatorName, - programId: config.programId, - programName: config.programName, - startDate: config.startDate, - endDate: config.endDate, - timeField: config.timeField, - }; - - const srcUrl = - baseUrl + "?" + new URLSearchParams(removeUndefinedProperties(params)).toString(); - - const iframeRef: React.RefObject = React.createRef(); - - React.useEffect(() => { - const iframe = iframeRef.current; - - if (iframe !== null) { - // eslint-disable-next-line @typescript-eslint/no-misused-promises - iframe.addEventListener("load", async () => { - await setMapStyling(iframe); - setState(prevState => ({ ...prevState, type: "loaded" })); - }); - } - }, [iframeRef]); - - const isLoading = state.type === "loading"; - - return ( - - -
- -
-
-
- ); -}); - -const MapEditorIFrame = styled.iframe``; - -const styles: Record = { - wrapperVisible: { width: "100%", height: "80vh" }, - wrapperHidden: { visibility: "hidden", width: "100%" }, -}; - -function removeUndefinedProperties(obj: T): Partial { - return Object.entries(obj).reduce((acc, [key, value]) => { - return value === undefined ? acc : { ...acc, [key]: value }; - }, {} as Partial); -} - -function waitforDocumentToLoad(iframeDocument: Document, selector: string) { - return new Promise(resolve => { - const check = () => { - if (iframeDocument.querySelector(selector)) { - resolve(undefined); - } else { - setTimeout(check, 1000); - } - }; - check(); - }); -} - -function waitforElementToLoad(element: HTMLElement | Document, selector: string) { - return new Promise(resolve => { - const check = () => { - if (element.querySelector(selector)) { - resolve(undefined); - } else { - setTimeout(check, 1000); - } - }; - check(); - }); -} - -async function setMapStyling(iframe: HTMLIFrameElement) { - if (!iframe.contentWindow) return; - const iframeDocument = iframe.contentWindow.document; - - await waitforDocumentToLoad(iframeDocument, "#dhis2-app-root"); - await waitforElementToLoad(iframeDocument, "header"); - await waitforElementToLoad(iframeDocument, ".dhis2-map-container-wrapper"); - - const iFrameRoot = iframeDocument.querySelector("#dhis2-app-root"); - - iframeDocument.querySelectorAll("header").forEach(el => el.remove()); - iFrameRoot?.querySelectorAll("header").forEach(el => el.remove()); - - iframeDocument.querySelectorAll(".app-menu-container").forEach(el => el.remove()); - iFrameRoot?.querySelectorAll(".app-menu-container").forEach(el => el.remove()); - - iframeDocument.querySelectorAll(".layers-toggle-container").forEach(el => el.remove()); - iFrameRoot?.querySelectorAll(".layers-toggle-container").forEach(el => el.remove()); - - iframeDocument.querySelectorAll(".layers-panel-drawer").forEach(el => el.remove()); - iFrameRoot?.querySelectorAll(".layers-panel-drawer").forEach(el => el.remove()); - - const mapContainerWrapper = iframeDocument.querySelector( - ".dhis2-map-container-wrapper" - ); - if (mapContainerWrapper) mapContainerWrapper.style.inset = "0px"; -} diff --git a/src/webapp/components/map/MapSection.tsx b/src/webapp/components/map/MapSection.tsx index ebf58741..8c966ece 100644 --- a/src/webapp/components/map/MapSection.tsx +++ b/src/webapp/components/map/MapSection.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo } from "react"; import styled from "styled-components"; import { useSnackbar } from "@eyeseetea/d2-ui-components"; -import { Map } from "./Map"; +import { Visualisation } from "../visualisation/Visualisation"; import { useMap } from "./useMap"; import { MapKey } from "../../../domain/entities/MapConfig"; import LoaderContainer from "../loader/LoaderContainer"; @@ -18,6 +18,7 @@ type MapSectionProps = { }; export const MapSection: React.FC = React.memo(props => { + const { api } = useAppContext(); const { mapKey, singleSelectFilters, @@ -44,27 +45,59 @@ export const MapSection: React.FC = React.memo(props => { eventHazardCode: eventHazardCode, }); + const baseUrl = `${api.baseUrl}/api/apps/zebra-custom-maps-app/index.html`; + const [mapUrl, setMapUrl] = React.useState(); + useEffect(() => { if (mapConfigState.kind === "error") { snackbar.error(mapConfigState.message); + } else if (mapConfigState.kind === "loaded") { + const config = mapConfigState.data; + const params = { + currentApp: config.currentApp, + currentPage: config.currentPage, + zebraNamespace: config.zebraNamespace, + dashboardDatastoreKey: config.dashboardDatastoreKey, + id: config.mapId, + orgUnits: config.orgUnits.join(","), + programIndicatorId: config.programIndicatorId, + programIndicatorName: config.programIndicatorName, + programId: config.programId, + programName: config.programName, + startDate: config.startDate, + endDate: config.endDate, + timeField: config.timeField, + }; + const srcUrl = + baseUrl + "?" + new URLSearchParams(removeUndefinedProperties(params)).toString(); + setMapUrl(srcUrl); } - }, [mapConfigState, snackbar]); + }, [baseUrl, mapConfigState, snackbar]); if (mapConfigState.kind === "error") { return
{mapConfigState.message}
; } + function removeUndefinedProperties(obj: T): Partial { + return Object.entries(obj).reduce((acc, [key, value]) => { + return value === undefined ? acc : { ...acc, [key]: value }; + }, {} as Partial); + } + return ( - {mapConfigState.kind === "loaded" && allProvincesIds.length !== 0 ? ( - ) : null} diff --git a/src/webapp/components/visualisation/Visualisation.tsx b/src/webapp/components/visualisation/Visualisation.tsx index 7f6bf966..733df25c 100644 --- a/src/webapp/components/visualisation/Visualisation.tsx +++ b/src/webapp/components/visualisation/Visualisation.tsx @@ -1,36 +1,142 @@ import React from "react"; -import { VisualizationTypes } from "../../pages/event-tracker/EventTrackerPage"; import styled from "styled-components"; -import { Section } from "../section/Section"; +import LoaderContainer from "../loader/LoaderContainer"; type VisualisationProps = { - type: VisualizationTypes; - title: string; - hasSeparator?: boolean; - lastUpdated?: string; + type: "map" | "chart"; + srcUrl: string; }; + +type State = { + type: "loading" | "loaded"; +}; + export const Visualisation: React.FC = React.memo(props => { - const { title, hasSeparator, lastUpdated } = props; + const { srcUrl, type } = props; + + const [state, setState] = React.useState({ type: "loading" }); + + const iframeRef: React.RefObject = React.createRef(); + + React.useEffect(() => { + const iframe = iframeRef.current; + + if (iframe !== null) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + iframe.addEventListener("load", async () => { + if (type === "map") { + await setMapStyling(iframe); + setState(prevState => ({ ...prevState, type: "loaded" })); + } else { + await setChartStyling(iframe); + setState(prevState => ({ ...prevState, type: "loaded" })); + } + }); + } + }, [iframeRef, type]); + + const isLoading = state.type === "loading"; + return ( -
- {`Coming soon!`} -
+ + +
+ +
+
+
); }); -const VisualisationContainer = styled.div` - width: 100%; - height: 25rem; - border: 0.1rem solid ${props => props.theme.palette.divider}; - background: ${props => props.theme.palette.background.paper}; - color: ${props => props.theme.palette.text.disabled}; - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 2rem; -`; +const VisualisationIFrame = styled.iframe``; + +const styles: Record = { + wrapperVisible: { width: "100%", height: "80vh" }, + wrapperHidden: { visibility: "hidden", width: "100%" }, +}; + +function waitforDocumentToLoad(iframeDocument: Document, selector: string) { + return new Promise(resolve => { + const check = () => { + if (iframeDocument.querySelector(selector)) { + resolve(undefined); + } else { + setTimeout(check, 1000); + } + }; + check(); + }); +} + +function waitforElementToLoad(element: HTMLElement | Document, selector: string) { + return new Promise(resolve => { + const check = () => { + if (element.querySelector(selector)) { + resolve(undefined); + } else { + setTimeout(check, 1000); + } + }; + check(); + }); +} + +async function setMapStyling(iframe: HTMLIFrameElement) { + if (!iframe.contentWindow) return; + const iframeDocument = iframe.contentWindow.document; + + await waitforDocumentToLoad(iframeDocument, "#dhis2-app-root"); + await waitforElementToLoad(iframeDocument, "header"); + await waitforElementToLoad(iframeDocument, ".dhis2-map-container-wrapper"); + + const iFrameRoot = iframeDocument.querySelector("#dhis2-app-root"); + + iframeDocument.querySelectorAll("header").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll("header").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".app-menu-container").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".app-menu-container").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".layers-toggle-container").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".layers-toggle-container").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".layers-panel-drawer").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".layers-panel-drawer").forEach(el => el.remove()); + + const mapContainerWrapper = iframeDocument.querySelector( + ".dhis2-map-container-wrapper" + ); + if (mapContainerWrapper) mapContainerWrapper.style.inset = "0px"; +} + +async function setChartStyling(iframe: HTMLIFrameElement) { + if (!iframe.contentWindow) return; + const iframeDocument = iframe.contentWindow.document; + + await waitforDocumentToLoad(iframeDocument, "#dhis2-app-root"); + await waitforElementToLoad(iframeDocument, "header"); + await waitforElementToLoad(iframeDocument, ".data-visualizer-app"); + + const iFrameRoot = iframeDocument.querySelector("#dhis2-app-root"); + + iframeDocument.querySelectorAll("header").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll("header").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".main-left").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".main-left").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".section-toolbar").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".section-toolbar").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".main-center-layout").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".main-center-layout").forEach(el => el.remove()); + + iframeDocument.querySelectorAll(".main-center-titlebar").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".main-center-titlebar").forEach(el => el.remove()); +} diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 85b1484b..10aab740 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -7,7 +7,7 @@ import { AddCircleOutline, EditOutlined } from "@material-ui/icons"; import i18n from "../../../utils/i18n"; import { Layout } from "../../components/layout/Layout"; import { FormSummary } from "../../components/form/form-summary/FormSummary"; -import { Visualisation } from "../../components/visualisation/Visualisation"; +import { Chart } from "../../components/chart/Chart"; import { Section } from "../../components/section/Section"; import { BasicTable, TableColumn } from "../../components/table/BasicTable"; import { getDateAsLocaleDateTimeString } from "../../../data/repositories/utils/DateTimeHelper"; @@ -18,16 +18,7 @@ import { MapSection } from "../../components/map/MapSection"; import LoaderContainer from "../../components/loader/LoaderContainer"; import { useMapFilters } from "./useMapFilters"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; - -// TODO: Add every section here -export type VisualizationTypes = - | "ALL_EVENTS_MAP" - | "EVENT_TRACKER_AREAS_AFFECTED_MAP" - | "RISK_ASSESSMENT_HISTORY_LINE_CHART" - | "EVENT_TRACKER_CASES_BAR_CHART" - | "EVENT_TRACKER_DEATHS_BAR_CHART" - | "EVENT_TRACKER_OVERVIEW_CARDS" - | "EVENT_TRACKER_717_CARDS"; +import { NoticeBox } from "../../components/notice-box/NoticeBox"; //TO DO : Create Risk assessment section export const riskAssessmentColumns: TableColumn[] = [ @@ -52,6 +43,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { useDiseaseOutbreakEvent(id); const { changeCurrentEventTracker: changeCurrentEventTrackerId, getCurrentEventTracker } = useCurrentEventTracker(); + const currentEventTracker = getCurrentEventTracker(); const { dateRangeFilter } = useMapFilters(); @@ -90,14 +82,14 @@ export const EventTrackerPage: React.FC = React.memo(() => { @@ -131,28 +123,45 @@ export const EventTrackerPage: React.FC = React.memo(() => { ) } titleVariant="secondary" - lastUpdated={lastUpdated} > - + {riskAssessmentRows.length > 0 ? ( + + ) : ( + + {i18n.t("Risks associated with this event have not yet been assessed.")} + + )} + {!!currentEventTracker?.riskAssessment?.grading?.length && ( + + )} +
+ +
+ +
- - - - -
); }); From cedbb794ab44445430a9c5cd199ab78969967fcc Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Mon, 7 Oct 2024 00:25:51 +0530 Subject: [PATCH 07/24] feat: 717 indicators for event tracker page --- i18n/en.pot | 13 ++++--- i18n/es.po | 14 ++++++- .../PerformanceOverviewD2Repository.ts | 23 +++++++++-- .../consts/PerformanceOverviewConstants.ts | 22 +++++++---- .../test/PerformanceOverviewTestRepository.ts | 6 ++- .../PerformanceOverviewMetrics.ts | 2 +- .../PerformanceOverviewRepository.ts | 3 +- .../usecases/Get717PerformanceUseCase.ts | 8 +++- src/webapp/pages/dashboard/DashboardPage.tsx | 28 ++++++------- .../pages/dashboard/use717Performance.ts | 39 +++++++++---------- .../pages/event-tracker/EventTrackerPage.tsx | 20 ++++++++++ 11 files changed, 121 insertions(+), 57 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 2bafc36a..767d0315 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-30T07:39:32.843Z\n" -"PO-Revision-Date: 2024-09-30T07:39:32.843Z\n" +"POT-Creation-Date: 2024-10-06T18:13:02.378Z\n" +"PO-Revision-Date: 2024-10-06T18:13:02.378Z\n" msgid "Low" msgstr "" @@ -90,9 +90,6 @@ msgstr "" msgid "Notes" msgstr "" -msgid "Notes" -msgstr "" - msgid "Create Event" msgstr "" @@ -156,6 +153,12 @@ msgstr "" msgid "Add new Assessment" msgstr "" +msgid "Risk assessment incomplete" +msgstr "" + +msgid "Risks associated with this event have not yet been assessed." +msgstr "" + msgid "N/A" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index ead7a636..1afbac47 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-16T13:53:55.684Z\n" +"POT-Creation-Date: 2024-10-06T18:13:02.378Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -86,6 +86,9 @@ msgstr "" msgid "Edit Details" msgstr "" +msgid "Notes" +msgstr "" + msgid "Create Event" msgstr "" @@ -131,6 +134,9 @@ msgstr "" msgid "7-1-7 performance" msgstr "" +msgid "events" +msgstr "" + msgid "Performance overview" msgstr "" @@ -146,6 +152,12 @@ msgstr "" msgid "Add new Assessment" msgstr "" +msgid "Risk assessment incomplete" +msgstr "" + +msgid "Risks associated with this event have not yet been assessed." +msgstr "" + msgid "N/A" msgstr "" diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index 479d4031..e5e770a1 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -9,6 +9,7 @@ import { eventTrackerCountsIndicatorMap, PERFORMANCE_METRICS_717_IDS, IndicatorsId, + EVENT_TRACKER_717_IDS, } from "./consts/PerformanceOverviewConstants"; import moment from "moment"; import { @@ -209,11 +210,12 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos } private mapIndicatorsTo717PerformanceMetrics( - performanceMetric717Response: string[][] + performanceMetric717Response: string[][], + metricIdList: PerformanceMetrics717[] ): PerformanceMetrics717[] { return _( performanceMetric717Response.map(([id, value]) => { - const indicator = PERFORMANCE_METRICS_717_IDS.find(d => d.id === id); + const indicator = metricIdList.find(d => d.id === id); if (!indicator) throw new Error(`Unknown Indicator with id ${id} `); @@ -231,7 +233,7 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos .value(); } - get717Performance(): FutureData { + getDashboard717Performance(): FutureData { return apiToFuture( this.api.analytics.get({ dimension: [`dx:${PERFORMANCE_METRICS_717_IDS.map(({ id }) => id).join(";")}`], @@ -240,7 +242,20 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos includeMetadataDetails: true, }) ).map(res => { - return this.mapIndicatorsTo717PerformanceMetrics(res.rows); + return this.mapIndicatorsTo717PerformanceMetrics(res.rows, PERFORMANCE_METRICS_717_IDS); + }); + } + + getEventTracker717Performance(): FutureData { + return apiToFuture( + this.api.analytics.get({ + dimension: [`dx:${EVENT_TRACKER_717_IDS.map(({ id }) => id).join(";")}`], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + includeMetadataDetails: true, + }) + ).map(res => { + return this.mapIndicatorsTo717PerformanceMetrics(res.rows, EVENT_TRACKER_717_IDS); }); } diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index 34a89d63..812da2f0 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -267,17 +267,23 @@ export const eventTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ ]; export const PERFORMANCE_METRICS_717_IDS: PerformanceMetrics717[] = [ - { id: "VWazDAQ15Uw", name: "detection", type: "percent" }, // % of number of alerts that were detected within 7 days of date of emergence - { id: "jnJHR2D0cN3", name: "detection", type: "count" }, // Number of alerts notified to public health authorities within 1 day of detection + { id: "VWazDAQ15Uw", name: "detection", type: "primary" }, // % of number of alerts that were detected within 7 days of date of emergence + { id: "jnJHR2D0cN3", name: "detection", type: "secondary" }, // Number of alerts notified to public health authorities within 1 day of detection - { id: "dr4OT0ql4cl", name: "notification", type: "percent" }, // - { id: "K3cqDGAQwWe", name: "notification", type: "count" }, // # events response action started 1 day + { id: "dr4OT0ql4cl", name: "notification", type: "primary" }, // + { id: "K3cqDGAQwWe", name: "notification", type: "secondary" }, // # events response action started 1 day - { id: "PQbR3lpD6my", name: "response", type: "percent" }, // % num of alerts responded d within 7d date not - { id: "ZX0uPp3ik81", name: "response", type: "count" }, // # events response action started 1 day + { id: "PQbR3lpD6my", 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: "gsHTYKBOLbb", name: "allTargets", type: "percent" }, // % num of alerts detected within 7d date emergence - { id: "FbPj6kekXBj", name: "allTargets", type: "count" }, + { id: "gsHTYKBOLbb", name: "allTargets", type: "primary" }, // % num of alerts detected within 7d date emergence + { id: "FbPj6kekXBj", 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" }, ]; // TODO To be updated with allTargets and event count diff --git a/src/data/repositories/test/PerformanceOverviewTestRepository.ts b/src/data/repositories/test/PerformanceOverviewTestRepository.ts index 5ef2602f..869abeef 100644 --- a/src/data/repositories/test/PerformanceOverviewTestRepository.ts +++ b/src/data/repositories/test/PerformanceOverviewTestRepository.ts @@ -1,12 +1,16 @@ +import { PerformanceMetrics717 } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { Future } from "../../../domain/entities/generic/Future"; import { PerformanceOverviewRepository } from "../../../domain/repositories/PerformanceOverviewRepository"; import { FutureData } from "../../api-futures"; export class PerformanceOverviewTestRepository implements PerformanceOverviewRepository { + getEventTracker717Performance(): FutureData { + return Future.success([]); + } getTotalCardCounts(): FutureData { return Future.success(0); } - get717Performance(): FutureData { + getDashboard717Performance(): FutureData { return Future.success(0); } diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts index b7979539..76663219 100644 --- a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -71,6 +71,6 @@ export type TotalCardCounts = DiseaseCounts | HazardCounts; export type PerformanceMetrics717 = { id: string; name: string; - type: "count" | "percent"; + type: "primary" | "secondary"; value?: number; }; diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index a649f7e7..7818b8f3 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -16,5 +16,6 @@ export interface PerformanceOverviewRepository { multiSelectFilters?: Record, dateRangeFilter?: string[] ): FutureData; - get717Performance(): FutureData; + getDashboard717Performance(): FutureData; + getEventTracker717Performance(): FutureData; } diff --git a/src/domain/usecases/Get717PerformanceUseCase.ts b/src/domain/usecases/Get717PerformanceUseCase.ts index ea224044..27af91ed 100644 --- a/src/domain/usecases/Get717PerformanceUseCase.ts +++ b/src/domain/usecases/Get717PerformanceUseCase.ts @@ -9,7 +9,11 @@ export class Get717PerformanceUseCase { } ) {} - public execute(): FutureData { - return this.options.performanceOverviewRepository.get717Performance(); + public execute(type: "dashboard" | "event_tracker"): FutureData { + if (type === "event_tracker") { + return this.options.performanceOverviewRepository.getEventTracker717Performance(); + } else if (type === "dashboard") { + return this.options.performanceOverviewRepository.getDashboard717Performance(); + } else throw new Error(`Unknown 717 type: ${type} `); } } diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index a84b273c..90a9b6dd 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -41,7 +41,7 @@ export const DashboardPage: React.FC = React.memo(() => { isLoading: performanceOverviewLoading, } = usePerformanceOverview(); - const { performanceMetrics717, isLoading: _717CardsLoading } = use717Performance(); + const { performanceMetrics717, isLoading: _717CardsLoading } = use717Performance("dashboard"); const { cardCounts, isLoading: cardCountsLoading } = useCardCounts( singleSelectFilters, multiSelectFilters, @@ -127,17 +127,19 @@ export const DashboardPage: React.FC = React.memo(() => {
- {performanceMetrics717.map((per: PerformanceMetric717, index: number) => ( - - ))} + {performanceMetrics717.map( + (perfMetric717: PerformanceMetric717, index: number) => ( + + ) + )}
@@ -158,7 +160,7 @@ export const DashboardPage: React.FC = React.memo(() => { ); }); -const GridWrapper = styled.div` +export const GridWrapper = styled.div` width: 100%; display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); diff --git a/src/webapp/pages/dashboard/use717Performance.ts b/src/webapp/pages/dashboard/use717Performance.ts index f3d3ac91..3c8da6dd 100644 --- a/src/webapp/pages/dashboard/use717Performance.ts +++ b/src/webapp/pages/dashboard/use717Performance.ts @@ -8,8 +8,8 @@ type CardColors = StatsCardProps["color"]; export type PerformanceMetric717 = { title: string; - percent: number; - count: number; + primaryValue: number; + secondaryValue: number; color: CardColors; }; export type PerformanceMetric717State = { @@ -19,40 +19,37 @@ export type PerformanceMetric717State = { export type Order = { name: string; direction: "asc" | "desc" }; -export function use717Performance(): PerformanceMetric717State { +export function use717Performance(type: "dashboard" | "event_tracker"): PerformanceMetric717State { const { compositionRoot } = useAppContext(); const [performanceMetrics717, setPerformanceMetrics717] = useState([]); const [isLoading, setIsLoading] = useState(false); - const getColor = useCallback((key: string, percent: number): CardColors => { + const getColor = useCallback((key: string, value: number): CardColors => { if (key === "allTargets") { return "grey"; - } else if (percent >= 50) { + } else if (value >= 50) { return "green"; - } else if (percent > 0) { + } else if (value > 0) { return "red"; } return "normal"; }, []); const transformData = useCallback( - (performanceIndicators: PerformanceMetrics717[]) => { - const performanceIndicatorsByName = _(performanceIndicators).reduce( - (acc: Record, indicator) => { + (performanceMetrics: PerformanceMetrics717[]) => { + const performanceMetricsByName = _(performanceMetrics).reduce( + (acc: Record, indicator) => { const key = indicator.name; const existingGroup = acc[key] || []; acc[key] = [...existingGroup, indicator]; return acc; }, - {} as Record + {} as Record ); - return Object.entries(performanceIndicatorsByName).map(([key, values]) => { - const percentObj = values.find(item => item.type === "percent"); - const countObj = values.find(item => item.type === "count"); - - const percent = percentObj?.value ?? 0; - const count = countObj?.value ?? 0; + return Object.entries(performanceMetricsByName).map(([key, values]) => { + const primaryValue = values.find(item => item.type === "primary")?.value ?? 0; + const secondaryValue = values.find(item => item.type === "secondary")?.value ?? 0; const title = key .replace(/([A-Z])/g, match => ` ${match}`) @@ -60,9 +57,9 @@ export function use717Performance(): PerformanceMetric717State { .trim(); return { title: title, - percent: percent, - count: count, - color: getColor(key, percent), + primaryValue: primaryValue, + secondaryValue: secondaryValue, + color: getColor(key, primaryValue), }; }); }, @@ -71,7 +68,7 @@ export function use717Performance(): PerformanceMetric717State { useEffect(() => { setIsLoading(true); - compositionRoot.performanceOverview.get717Performance.execute().run( + compositionRoot.performanceOverview.get717Performance.execute(type).run( performanceMetrics717 => { setPerformanceMetrics717(transformData(performanceMetrics717)); setIsLoading(false); @@ -81,7 +78,7 @@ export function use717Performance(): PerformanceMetric717State { setIsLoading(false); } ); - }, [compositionRoot.performanceOverview.get717Performance, transformData]); + }, [compositionRoot.performanceOverview.get717Performance, transformData, type]); return { performanceMetrics717, diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 10aab740..e69c7283 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -19,6 +19,9 @@ import LoaderContainer from "../../components/loader/LoaderContainer"; import { useMapFilters } from "./useMapFilters"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; import { NoticeBox } from "../../components/notice-box/NoticeBox"; +import { PerformanceMetric717, use717Performance } from "../dashboard/use717Performance"; +import { GridWrapper } from "../dashboard/DashboardPage"; +import { StatsCard } from "../../components/stats-card/StatsCard"; //TO DO : Create Risk assessment section export const riskAssessmentColumns: TableColumn[] = [ @@ -52,6 +55,8 @@ export const EventTrackerPage: React.FC = React.memo(() => { formType: "risk-assessment-summary", }); }, [goTo]); + const { performanceMetrics717, isLoading: _717CardsLoading } = + use717Performance("event_tracker"); useEffect(() => { if (eventTrackerDetails) changeCurrentEventTrackerId(eventTrackerDetails); @@ -162,6 +167,21 @@ export const EventTrackerPage: React.FC = React.memo(() => { } />
+
+ + {performanceMetrics717.map( + (perfMetric: PerformanceMetric717, index: number) => ( + + ) + )} + +
); }); From 030ad5640c2bc0e7aa12f8447a6a711594ffd976 Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Mon, 7 Oct 2024 20:39:22 +0530 Subject: [PATCH 08/24] fix: correct 717 indicator ids --- .../consts/PerformanceOverviewConstants.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index 812da2f0..d738487f 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -267,17 +267,17 @@ export const eventTrackerCountsIndicatorMap: EventTrackerCountIndicator[] = [ ]; export const PERFORMANCE_METRICS_717_IDS: PerformanceMetrics717[] = [ - { id: "VWazDAQ15Uw", name: "detection", type: "primary" }, // % of number of alerts that were detected within 7 days of date of emergence - { id: "jnJHR2D0cN3", name: "detection", type: "secondary" }, // Number of alerts notified to public health authorities within 1 day of detection + { 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: "dr4OT0ql4cl", name: "notification", type: "primary" }, // - { id: "K3cqDGAQwWe", name: "notification", type: "secondary" }, // # events response action started 1 day + { id: "Y6OkqfhGhZb", name: "notification", type: "primary" }, // + { id: "fKvY7kMydl1", name: "notification", type: "secondary" }, // # events response action started 1 day - { id: "PQbR3lpD6my", name: "response", type: "primary" }, // % num of alerts responded d within 7d date not + { 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: "gsHTYKBOLbb", name: "allTargets", type: "primary" }, // % num of alerts detected within 7d date emergence - { id: "FbPj6kekXBj", name: "allTargets", type: "secondary" }, + { id: "bs4E7tV8QRN", name: "allTargets", type: "primary" }, // % num of alerts detected within 7d date emergence + { id: "dr4OT0ql4cl", name: "allTargets", type: "secondary" }, ]; export const EVENT_TRACKER_717_IDS: PerformanceMetrics717[] = [ From 1031df85858cb2b5089d0e1742316bbe2add8762 Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Mon, 7 Oct 2024 21:54:31 +0530 Subject: [PATCH 09/24] feat: last analytics runtime --- src/CompositionRoot.ts | 8 ++++++ src/data/repositories/SystemD2Repository.ts | 21 +++++++++++++++ .../consts/PerformanceOverviewConstants.ts | 1 - .../repositories/test/SystemTestRepository.ts | 9 +++++++ .../PerformanceOverviewMetrics.ts | 1 - src/domain/repositories/SystemRepository.ts | 5 ++++ .../usecases/GetAnalyticsRuntimeUseCase.ts | 14 ++++++++++ src/webapp/components/layout/Layout.tsx | 3 +++ .../layout/main-content/MainContent.tsx | 26 +++++++++++++++++++ src/webapp/hooks/useLastAnalyticsRuntime.ts | 20 ++++++++++++++ src/webapp/pages/dashboard/DashboardPage.tsx | 8 +++++- .../pages/event-tracker/EventTrackerPage.tsx | 13 +++------- .../mapRiskAssessmentToInitialFormState.ts | 8 ------ 13 files changed, 117 insertions(+), 20 deletions(-) create mode 100644 src/data/repositories/SystemD2Repository.ts create mode 100644 src/data/repositories/test/SystemTestRepository.ts create mode 100644 src/domain/repositories/SystemRepository.ts create mode 100644 src/domain/usecases/GetAnalyticsRuntimeUseCase.ts create mode 100644 src/webapp/hooks/useLastAnalyticsRuntime.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 006d3611..422f8652 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -46,6 +46,10 @@ import { ChartConfigRepository } from "./domain/repositories/ChartConfigReposito import { GetChartConfigByTypeUseCase } from "./domain/usecases/GetChartConfigByTypeUseCase"; import { ChartConfigTestRepository } from "./data/repositories/test/ChartConfigTestRepository"; import { ChartConfigD2Repository } from "./data/repositories/ChartConfigD2Repository"; +import { GetAnalyticsRuntimeUseCase } from "./domain/usecases/GetAnalyticsRuntimeUseCase"; +import { SystemRepository } from "./domain/repositories/SystemRepository"; +import { SystemD2Repository } from "./data/repositories/SystemD2Repository"; +import { SystemTestRepository } from "./data/repositories/test/SystemTestRepository"; export type CompositionRoot = ReturnType; @@ -61,6 +65,7 @@ type Repositories = { mapConfigRepository: MapConfigRepository; performanceOverviewRepository: PerformanceOverviewRepository; chartConfigRepository: ChartConfigRepository; + systemRepository: SystemRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -88,6 +93,7 @@ function getCompositionRoot(repositories: Repositories) { ), getTotalCardCounts: new GetTotalCardCountsUseCase(repositories), get717Performance: new Get717PerformanceUseCase(repositories), + getAnalyticsRuntime: new GetAnalyticsRuntimeUseCase(repositories), }, maps: { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), @@ -116,6 +122,7 @@ export function getWebappCompositionRoot(api: D2Api) { mapConfigRepository: new MapConfigD2Repository(api), performanceOverviewRepository: new PerformanceOverviewD2Repository(api, dataStoreClient), chartConfigRepository: new ChartConfigD2Repository(dataStoreClient), + systemRepository: new SystemD2Repository(api), }; return getCompositionRoot(repositories); @@ -134,6 +141,7 @@ export function getTestCompositionRoot() { mapConfigRepository: new MapConfigTestRepository(), performanceOverviewRepository: new PerformanceOverviewTestRepository(), chartConfigRepository: new ChartConfigTestRepository(), + systemRepository: new SystemTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/SystemD2Repository.ts b/src/data/repositories/SystemD2Repository.ts new file mode 100644 index 00000000..8a2edf00 --- /dev/null +++ b/src/data/repositories/SystemD2Repository.ts @@ -0,0 +1,21 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { SystemRepository } from "../../domain/repositories/SystemRepository"; +import { apiToFuture, FutureData } from "../api-futures"; +import { getDateAsLocaleDateTimeString } from "./utils/DateTimeHelper"; + +export class SystemD2Repository implements SystemRepository { + constructor(private api: D2Api) {} + + public getLastAnalyticsRuntime(): FutureData { + return apiToFuture(this.api.system.info).map(info => { + //TO DO : update d2Api repo to add lastAnalyticsTablePartitionSuccess to info + //@ts-ignore + if (info.lastAnalyticsTablePartitionSuccess) + return getDateAsLocaleDateTimeString( + //@ts-ignore + new Date(info.lastAnalyticsTablePartitionSuccess) + ); + else return "Unable to fetch last analytics runtime"; + }); + } +} diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index d738487f..cab613e6 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -21,7 +21,6 @@ export enum IndicatorsId { notify1d = "HDa3nE7Elxj", respond7d = "yxVOW4lj4xP", province = "ouname", - creationDate = "lastupdated", id = "tei", nationalIncidentStatus = "incidentStatus", } diff --git a/src/data/repositories/test/SystemTestRepository.ts b/src/data/repositories/test/SystemTestRepository.ts new file mode 100644 index 00000000..a23138ce --- /dev/null +++ b/src/data/repositories/test/SystemTestRepository.ts @@ -0,0 +1,9 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { SystemRepository } from "../../../domain/repositories/SystemRepository"; +import { FutureData } from "../../api-futures"; + +export class SystemTestRepository implements SystemRepository { + getLastAnalyticsRuntime(): FutureData { + return Future.success(new Date().toString()); + } +} diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts index 76663219..c51caafb 100644 --- a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -41,7 +41,6 @@ export type PerformanceOverviewMetrics = { detect7d: string; notify1d: string; respond7d: string; - creationDate: string; suspectedDisease: DiseaseNames; hazardType: HazardNames; nationalIncidentStatus: string; diff --git a/src/domain/repositories/SystemRepository.ts b/src/domain/repositories/SystemRepository.ts new file mode 100644 index 00000000..8549a9a1 --- /dev/null +++ b/src/domain/repositories/SystemRepository.ts @@ -0,0 +1,5 @@ +import { FutureData } from "../../data/api-futures"; + +export interface SystemRepository { + getLastAnalyticsRuntime(): FutureData; +} diff --git a/src/domain/usecases/GetAnalyticsRuntimeUseCase.ts b/src/domain/usecases/GetAnalyticsRuntimeUseCase.ts new file mode 100644 index 00000000..428d2b8f --- /dev/null +++ b/src/domain/usecases/GetAnalyticsRuntimeUseCase.ts @@ -0,0 +1,14 @@ +import { FutureData } from "../../data/api-futures"; +import { SystemRepository } from "../repositories/SystemRepository"; + +export class GetAnalyticsRuntimeUseCase { + constructor( + private options: { + systemRepository: SystemRepository; + } + ) {} + + public execute(): FutureData { + return this.options.systemRepository.getLastAnalyticsRuntime(); + } +} diff --git a/src/webapp/components/layout/Layout.tsx b/src/webapp/components/layout/Layout.tsx index e8b04bf5..af98ac29 100644 --- a/src/webapp/components/layout/Layout.tsx +++ b/src/webapp/components/layout/Layout.tsx @@ -12,6 +12,7 @@ type LayoutProps = { subtitle?: string; hideSideBarOptions?: boolean; showCreateEvent?: boolean; + lastAnalyticsRuntime?: string; }; export const Layout: React.FC = React.memo( @@ -21,6 +22,7 @@ export const Layout: React.FC = React.memo( subtitle = "", hideSideBarOptions = false, showCreateEvent = false, + lastAnalyticsRuntime = "", }) => { const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down("sm")); @@ -41,6 +43,7 @@ export const Layout: React.FC = React.memo( showCreateEvent={showCreateEvent} title={title} subtitle={subtitle} + lastAnalyticsRuntime={lastAnalyticsRuntime} > {children} diff --git a/src/webapp/components/layout/main-content/MainContent.tsx b/src/webapp/components/layout/main-content/MainContent.tsx index 696557a2..6e0554e1 100644 --- a/src/webapp/components/layout/main-content/MainContent.tsx +++ b/src/webapp/components/layout/main-content/MainContent.tsx @@ -11,6 +11,7 @@ type MainContentProps = { sideBarOpen: boolean; toggleSideBar: (isOpen: boolean) => void; showCreateEvent?: boolean; + lastAnalyticsRuntime?: string; }; export const MainContent: React.FC = React.memo( @@ -22,6 +23,7 @@ export const MainContent: React.FC = React.memo( showCreateEvent = false, toggleSideBar, sideBarOpen, + lastAnalyticsRuntime = "", }) => { return ( @@ -37,6 +39,13 @@ export const MainContent: React.FC = React.memo( {subtitle && {subtitle}} + {lastAnalyticsRuntime && ( + + Last Analytics Runtime : + {lastAnalyticsRuntime} + + )} + {children} @@ -61,6 +70,23 @@ const SubTitle = styled.span` color: ${props => props.theme.palette.text.secondary}; `; +const AnalyticsRuntime = styled.span` + align-self: flex-end; + margin-block-start: 8px; +`; + +const EmphasisedText = styled.span` + color: ${props => props.theme.palette.common.grey700}; + font-size: 0.875rem; + font-weight: 700; +`; + +const Text = styled.span` + color: ${props => props.theme.palette.common.grey700}; + font-size: 0.875rem; + font-weight: 400; +`; + const PageContent = styled.div` margin-block-start: 48px; `; diff --git a/src/webapp/hooks/useLastAnalyticsRuntime.ts b/src/webapp/hooks/useLastAnalyticsRuntime.ts new file mode 100644 index 00000000..830aad76 --- /dev/null +++ b/src/webapp/hooks/useLastAnalyticsRuntime.ts @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../contexts/app-context"; + +export function useLastAnalyticsRuntime() { + const { compositionRoot } = useAppContext(); + const [lastAnalyticsRuntime, setLastAnalyticsRuntime] = useState(); + + useEffect(() => { + compositionRoot.performanceOverview.getAnalyticsRuntime.execute().run( + analyticsRuntime => { + setLastAnalyticsRuntime(analyticsRuntime); + }, + err => { + console.debug(err); + } + ); + }, [compositionRoot.performanceOverview.getAnalyticsRuntime]); + + return { lastAnalyticsRuntime }; +} diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 90a9b6dd..48b39450 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -19,6 +19,7 @@ import { Selector } from "../../components/selector/Selector"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; import { PerformanceMetric717, use717Performance } from "./use717Performance"; import { Loader } from "../../components/loader/Loader"; +import { useLastAnalyticsRuntime } from "../../hooks/useLastAnalyticsRuntime"; export const DashboardPage: React.FC = React.memo(() => { const { @@ -50,6 +51,7 @@ export const DashboardPage: React.FC = React.memo(() => { const { goTo } = useRoutes(); const { resetCurrentEventTracker: resetCurrentEventTrackerId } = useCurrentEventTracker(); + const { lastAnalyticsRuntime } = useLastAnalyticsRuntime(); useEffect(() => { //On navigating to the dashboard page, reset the current event tracker id @@ -64,7 +66,11 @@ export const DashboardPage: React.FC = React.memo(() => { return performanceOverviewLoading || _717CardsLoading || cardCountsLoading ? ( ) : ( - +
{selectorFiltersConfig.map(({ id, label, placeholder, options, type }) => { diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index e69c7283..8c86fe20 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -10,7 +10,6 @@ import { FormSummary } from "../../components/form/form-summary/FormSummary"; import { Chart } from "../../components/chart/Chart"; import { Section } from "../../components/section/Section"; import { BasicTable, TableColumn } from "../../components/table/BasicTable"; -import { getDateAsLocaleDateTimeString } from "../../../data/repositories/utils/DateTimeHelper"; import { useDiseaseOutbreakEvent } from "./useDiseaseOutbreakEvent"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; @@ -22,6 +21,7 @@ import { NoticeBox } from "../../components/notice-box/NoticeBox"; import { PerformanceMetric717, use717Performance } from "../dashboard/use717Performance"; import { GridWrapper } from "../dashboard/DashboardPage"; import { StatsCard } from "../../components/stats-card/StatsCard"; +import { useLastAnalyticsRuntime } from "../../hooks/useLastAnalyticsRuntime"; //TO DO : Create Risk assessment section export const riskAssessmentColumns: TableColumn[] = [ @@ -47,6 +47,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { const { changeCurrentEventTracker: changeCurrentEventTrackerId, getCurrentEventTracker } = useCurrentEventTracker(); const currentEventTracker = getCurrentEventTracker(); + const { lastAnalyticsRuntime } = useLastAnalyticsRuntime(); const { dateRangeFilter } = useMapFilters(); @@ -62,21 +63,15 @@ export const EventTrackerPage: React.FC = React.memo(() => { if (eventTrackerDetails) changeCurrentEventTrackerId(eventTrackerDetails); }, [changeCurrentEventTrackerId, eventTrackerDetails, id]); - const lastUpdated = getDateAsLocaleDateTimeString(new Date()); //TO DO : Fetch sync time from datastore once implemented return ( - + -
+
Date: Mon, 7 Oct 2024 21:55:31 +0530 Subject: [PATCH 10/24] chore: fix warn --- .../risk-assessment/mapRiskAssessmentToInitialFormState.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/webapp/pages/form-page/risk-assessment/mapRiskAssessmentToInitialFormState.ts b/src/webapp/pages/form-page/risk-assessment/mapRiskAssessmentToInitialFormState.ts index c5c20740..1c52f5ac 100644 --- a/src/webapp/pages/form-page/risk-assessment/mapRiskAssessmentToInitialFormState.ts +++ b/src/webapp/pages/form-page/risk-assessment/mapRiskAssessmentToInitialFormState.ts @@ -12,7 +12,6 @@ import { FormState } from "../../../components/form/FormState"; import { User } from "../../../components/user-selector/UserSelector"; import { Option as UIOption } from "../../../components/utils/option"; import { mapTeamMemberToUser, mapToPresentationOptions } from "../mapEntityToFormState"; -import { getDateAsLocaleDateTimeString } from "../../../../data/repositories/utils/DateTimeHelper"; import { FormSectionState } from "../../../components/form/FormSectionsState"; import { RiskAssessmentQuestionnaire } from "../../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; import { Maybe } from "../../../../utils/ts-utils"; From 26869d7e85990863e5d7ed0e0ca4c227ab10bede Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Tue, 8 Oct 2024 21:55:26 +0530 Subject: [PATCH 11/24] fix: misc date fixes --- .../DiseaseOutbreakEventD2Repository.ts | 2 +- .../RiskAssessmentD2Repository.ts | 3 +- src/data/repositories/SystemD2Repository.ts | 19 ++++++++++ .../utils/DiseaseOutbreakMapper.ts | 4 +- .../utils/RiskAssessmentMapper.ts | 4 +- .../DiseaseOutbreakEvent.ts | 4 +- .../risk-assessment/RiskAssessmentGrading.ts | 2 +- .../visualisation/Visualisation.tsx | 38 +++++++++++++++++-- .../event-tracker/useDiseaseOutbreakEvent.ts | 8 +++- 9 files changed, 71 insertions(+), 13 deletions(-) diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index d0056f75..768b0278 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -23,7 +23,7 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep program: RTSL_ZEBRA_PROGRAM_ID, orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, trackedEntity: id, - fields: { attributes: true, trackedEntity: true }, + fields: { attributes: true, trackedEntity: true, updatedAt: true }, }) ) .flatMap(response => assertOrError(response.instances[0], "Tracked entity")) diff --git a/src/data/repositories/RiskAssessmentD2Repository.ts b/src/data/repositories/RiskAssessmentD2Repository.ts index cc7a3b9c..ec42d6f7 100644 --- a/src/data/repositories/RiskAssessmentD2Repository.ts +++ b/src/data/repositories/RiskAssessmentD2Repository.ts @@ -156,12 +156,13 @@ export class RiskAssessmentD2Repository implements RiskAssessmentRepository { dataElement: { id: true, code: true }, value: true, }, + createdAt: true, trackedEntity: true, }, }) ).map(events => { const grading: RiskAssessmentGrading[] = events.instances.map(event => { - return mapDataElementsToRiskAssessmentGrading(event.dataValues); + return mapDataElementsToRiskAssessmentGrading(event.createdAt, event.dataValues); }); return grading; }); diff --git a/src/data/repositories/SystemD2Repository.ts b/src/data/repositories/SystemD2Repository.ts index 8a2edf00..f726b072 100644 --- a/src/data/repositories/SystemD2Repository.ts +++ b/src/data/repositories/SystemD2Repository.ts @@ -9,6 +9,25 @@ export class SystemD2Repository implements SystemRepository { public getLastAnalyticsRuntime(): FutureData { return apiToFuture(this.api.system.info).map(info => { //TO DO : update d2Api repo to add lastAnalyticsTablePartitionSuccess to info + + //@ts-ignore + const lastAnalyticsTablePartitionSuccess = info.lastAnalyticsTablePartitionSuccess; + //If continious analytics is turned on, return it. + if ( + info.lastAnalyticsTableSuccess && + lastAnalyticsTablePartitionSuccess && + new Date(lastAnalyticsTablePartitionSuccess) > + new Date(info.lastAnalyticsTableSuccess) + ) { + return getDateAsLocaleDateTimeString(new Date(lastAnalyticsTablePartitionSuccess)); + } + //Else, return the lastAnalyticsTableSuccess time + else if (info.lastAnalyticsTableSuccess) { + return getDateAsLocaleDateTimeString(new Date(info.lastAnalyticsTableSuccess)); + } else { + return "Unable to fetch last analytics runtime"; + } + //@ts-ignore if (info.lastAnalyticsTablePartitionSuccess) return getDateAsLocaleDateTimeString( diff --git a/src/data/repositories/utils/DiseaseOutbreakMapper.ts b/src/data/repositories/utils/DiseaseOutbreakMapper.ts index 1e2950fe..194829fe 100644 --- a/src/data/repositories/utils/DiseaseOutbreakMapper.ts +++ b/src/data/repositories/utils/DiseaseOutbreakMapper.ts @@ -47,8 +47,8 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( 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(), - lastUpdated: trackedEntity.updatedAt ? new Date(trackedEntity.updatedAt) : new Date(), + created: trackedEntity.createdAt ? new Date(trackedEntity.createdAt) : undefined, + lastUpdated: trackedEntity.updatedAt ? new Date(trackedEntity.updatedAt) : undefined, createdByName: undefined, hazardType: getHazardTypeByCode(fromMap("hazardType")), mainSyndromeCode: fromMap("mainSyndrome"), diff --git a/src/data/repositories/utils/RiskAssessmentMapper.ts b/src/data/repositories/utils/RiskAssessmentMapper.ts index ae433941..a719f362 100644 --- a/src/data/repositories/utils/RiskAssessmentMapper.ts +++ b/src/data/repositories/utils/RiskAssessmentMapper.ts @@ -43,6 +43,7 @@ import { RiskAssessmentSummaryFormData, } from "../../../domain/entities/ConfigurableForm"; import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { getDateAsLocaleDateString } from "./DateTimeHelper"; type D2ProgramStageDataElementsMetadata = { dataElement: SelectedPick< @@ -254,6 +255,7 @@ function getRiskAssessmentTrackerEvent( } export function mapDataElementsToRiskAssessmentGrading( + lastUpdated: string | undefined, dataValues: DataValue[] ): RiskAssessmentGrading { const populationValue = getValueById(dataValues, riskAssessmentGradingIds.populationAtRisk); @@ -273,7 +275,7 @@ export function mapDataElementsToRiskAssessmentGrading( const riskAssessmentGrading: RiskAssessmentGrading = RiskAssessmentGrading.create({ id: "", - lastUpdated: new Date(), + lastUpdated: lastUpdated ? new Date(lastUpdated) : undefined, populationAtRisk: RiskAssessmentGrading.getOptionTypeByCodePopulation(populationValue), attackRate: RiskAssessmentGrading.getOptionTypeByCodeWeighted(attackRateValue), geographicalSpread: diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index 316d335f..5e5983a8 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -55,8 +55,8 @@ type EarlyResponseActions = { export type DiseaseOutbreakEventBaseAttrs = NamedRef & { status: "ACTIVE" | "COMPLETED" | "CANCELLED"; - created: Date; - lastUpdated: Date; + created?: Date; + lastUpdated?: Date; createdByName: Maybe; dataSource: DataSource; hazardType: Maybe; diff --git a/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts b/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts index f1f2b91a..b450c739 100644 --- a/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts +++ b/src/domain/entities/risk-assessment/RiskAssessmentGrading.ts @@ -116,7 +116,7 @@ const translations: Record = { }; export interface RiskAssessmentGradingAttrs extends Ref { - lastUpdated: Date; + lastUpdated?: Date; populationAtRisk: LowPopulationAtRisk | MediumPopulationAtRisk | HighPopulationAtRisk; attackRate: LowWeightedOption | MediumWeightedOption | HighWeightedOption; geographicalSpread: LowGeographicalSpread | MediumGeographicalSpread | HighGeographicalSpread; diff --git a/src/webapp/components/visualisation/Visualisation.tsx b/src/webapp/components/visualisation/Visualisation.tsx index 733df25c..a7e2fa7f 100644 --- a/src/webapp/components/visualisation/Visualisation.tsx +++ b/src/webapp/components/visualisation/Visualisation.tsx @@ -19,6 +19,7 @@ export const Visualisation: React.FC = React.memo(props => { const iframeRef: React.RefObject = React.createRef(); React.useEffect(() => { + console.debug(`Loading ${type} visualisation from ${srcUrl}`); const iframe = iframeRef.current; if (iframe !== null) { @@ -28,12 +29,17 @@ export const Visualisation: React.FC = React.memo(props => { await setMapStyling(iframe); setState(prevState => ({ ...prevState, type: "loaded" })); } else { - await setChartStyling(iframe); - setState(prevState => ({ ...prevState, type: "loaded" })); + if (srcUrl.includes("dhis-web-data-visualizer")) { + await setChartStyling(iframe); + setState(prevState => ({ ...prevState, type: "loaded" })); + } else { + await setEventChartStyling(iframe); + setState(prevState => ({ ...prevState, type: "loaded" })); + } } }); } - }, [iframeRef, type]); + }, [iframeRef, srcUrl, type]); const isLoading = state.type === "loading"; @@ -140,3 +146,29 @@ async function setChartStyling(iframe: HTMLIFrameElement) { iframeDocument.querySelectorAll(".main-center-titlebar").forEach(el => el.remove()); iFrameRoot?.querySelectorAll(".main-center-titlebar").forEach(el => el.remove()); } + +async function setEventChartStyling(iframe: HTMLIFrameElement) { + if (!iframe.contentWindow) return; + const iframeDocument = iframe.contentWindow.document; + + await waitforDocumentToLoad(iframeDocument, "#viewport-1316-embedded-center"); + await waitforDocumentToLoad(iframeDocument, ".x-box-inner"); + + const iFrameRoot = iframeDocument.querySelector("#viewport-1316-embedded-center"); + console.debug(`iFrameRoot: ${iFrameRoot}`); + + console.debug(` toolbar-north : ${iframeDocument.querySelectorAll(".toolbar-north")}`); + iframeDocument.querySelectorAll(".toolbar-north").forEach(el => el.remove()); + iFrameRoot?.querySelectorAll(".toolbar-north").forEach(el => el.remove()); + + console.debug(`#panel-1305 : ${iframeDocument.querySelectorAll("#panel-1305")}`); + iframeDocument.querySelectorAll("#panel-1305").forEach(el => { + console.debug(`Removing element: ${el}`); + el.remove(); + }); + iFrameRoot?.querySelectorAll("#panel-1305").forEach(el => el.remove()); + + const eventChartContainer = iframeDocument.querySelector("#panel-1310"); + console.debug(`eventChartContainer: ${eventChartContainer}`); + if (eventChartContainer) eventChartContainer.style.inset = "0px"; +} diff --git a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts index 1c967931..e3daee54 100644 --- a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts +++ b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts @@ -71,7 +71,9 @@ export function useDiseaseOutbreakEvent(id: Id) { summary: [ { label: "Last updated", - value: getDateAsLocaleDateTimeString(diseaseOutbreakEvent.lastUpdated), + value: diseaseOutbreakEvent.lastUpdated + ? getDateAsLocaleDateTimeString(diseaseOutbreakEvent.lastUpdated) + : "", }, dataSourceLabelValue, { @@ -103,7 +105,9 @@ export function useDiseaseOutbreakEvent(id: Id) { ) => { if (diseaseOutbreakEvent.riskAssessment) { return diseaseOutbreakEvent.riskAssessment.grading.map(riskAssessmentGrading => ({ - riskAssessmentDate: getDateAsLocaleDateString(riskAssessmentGrading.lastUpdated), + riskAssessmentDate: riskAssessmentGrading.lastUpdated + ? getDateAsLocaleDateString(riskAssessmentGrading.lastUpdated) + : "", grade: RiskAssessmentGrading.getTranslatedLabel( riskAssessmentGrading.getGrade().getOrThrow() ), From 66f545f4db5f6f7037d846b8ec2f2de71a6dbc24 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 11:45:22 +0200 Subject: [PATCH 12/24] Add IM team builder page, add tree component, add repos, entities and use cases needed --- .../IncidentManagementTeamD2Repository.ts | 254 +++++++++++++++++ src/data/repositories/RoleD2Repository.ts | 52 ++++ .../IncidentManagementTeamBuilderConstants.ts | 47 ++++ .../IncidentManagementTeamTestRepository.ts | 55 ++++ .../repositories/test/RoleTestRepository.ts | 11 + .../utils/IncidentManagementTeamMapper.ts | 202 ++++++++++++++ src/data/repositories/utils/helpers.ts | 23 ++ .../entities/incident-management-team/Role.ts | 3 + .../IncidentManagementTeamRepository.ts | 23 ++ src/domain/repositories/RoleRepository.ts | 6 + ...IncidentManagementTeamMemberRoleUseCase.ts | 24 ++ .../GetIncidentManagementTeamByIdUseCase.ts | 24 ++ .../GetIncidentManagementTeamById.ts | 16 ++ .../GetIncidentManagementTeamWithOptions.ts | 66 +++++ .../im-team-hierarchy/IMTeamHierarchyItem.tsx | 138 +++++++++ .../im-team-hierarchy/IMTeamHierarchyView.tsx | 83 ++++++ .../im-team-hierarchy/TeamMemberProfile.tsx | 80 ++++++ .../components/simple-modal/SimpleModal.tsx | 82 ++++++ ...tManagementTeamMemberToInitialFormState.ts | 133 +++++++++ .../useIMTeamBuilder.ts | 264 ++++++++++++++++++ 20 files changed, 1586 insertions(+) create mode 100644 src/data/repositories/IncidentManagementTeamD2Repository.ts create mode 100644 src/data/repositories/RoleD2Repository.ts create mode 100644 src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts create mode 100644 src/data/repositories/test/IncidentManagementTeamTestRepository.ts create mode 100644 src/data/repositories/test/RoleTestRepository.ts create mode 100644 src/data/repositories/utils/IncidentManagementTeamMapper.ts create mode 100644 src/data/repositories/utils/helpers.ts create mode 100644 src/domain/entities/incident-management-team/Role.ts create mode 100644 src/domain/repositories/IncidentManagementTeamRepository.ts create mode 100644 src/domain/repositories/RoleRepository.ts create mode 100644 src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts create mode 100644 src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts create mode 100644 src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts create mode 100644 src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts create mode 100644 src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx create mode 100644 src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx create mode 100644 src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx create mode 100644 src/webapp/components/simple-modal/SimpleModal.tsx create mode 100644 src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts create mode 100644 src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts diff --git a/src/data/repositories/IncidentManagementTeamD2Repository.ts b/src/data/repositories/IncidentManagementTeamD2Repository.ts new file mode 100644 index 00000000..bea23b44 --- /dev/null +++ b/src/data/repositories/IncidentManagementTeamD2Repository.ts @@ -0,0 +1,254 @@ +import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; + +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { apiToFuture, FutureData } from "../api-futures"; +import { Future } from "../../domain/entities/generic/Future"; +import { + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + RTSL_ZEBRA_ORG_UNIT_ID, + RTSL_ZEBRA_PROGRAM_ID, +} from "./consts/DiseaseOutbreakConstants"; +import { Maybe } from "../../utils/ts-utils"; +import { IncidentManagementTeam } from "../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { IncidentManagementTeamRepository } from "../../domain/repositories/IncidentManagementTeamRepository"; +import { Id } from "../../domain/entities/Ref"; +import { + getTeamMemberIncidentManagementTeamRoles, + mapD2EventsToIncidentManagementTeam, + mapIncidentManagementTeamMemberToD2Event, +} from "./utils/IncidentManagementTeamMapper"; +import { TeamMember, TeamRole } from "../../domain/entities/incident-management-team/TeamMember"; +import { getProgramStage } from "./utils/MetadataHelper"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; +import { assertOrError } from "./utils/AssertOrError"; + +export class IncidentManagementTeamD2Repository implements IncidentManagementTeamRepository { + constructor(private api: D2Api) {} + + get( + diseaseOutbreakId: Id, + teamMembers: TeamMember[] + ): FutureData> { + return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( + ({ dataElementRoles, events }) => { + const maybeIncidentManagementTeam: Maybe = + mapD2EventsToIncidentManagementTeam(events, dataElementRoles, teamMembers); + return Future.success(maybeIncidentManagementTeam); + } + ); + } + + getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData { + return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( + ({ dataElementRoles, events }) => { + return apiToFuture( + this.api.metadata.get({ + users: { + fields: d2UserFields, + filter: { username: { eq: username } }, + }, + }) + ) + .flatMap(response => + assertOrError(response.users[0], "Incident Management Team Member") + ) + .map(d2User => + this.mapUserToIncidentManagementTeamMember( + d2User as D2UserFix, + events, + dataElementRoles + ) + ); + } + ); + } + + private mapUserToIncidentManagementTeamMember( + d2User: D2UserFix, + events: D2TrackerEvent[], + dataElementRoles: D2DataElement[] + ): TeamMember { + const avatarId = d2User?.avatar?.id; + const photoUrlString = avatarId + ? `${this.api.baseUrl}/api/fileResources/${avatarId}/data` + : undefined; + + const teamMember = new TeamMember({ + id: d2User.id, + username: d2User.username, + name: d2User.name, + email: d2User.email, + phone: d2User.phoneNumber, + status: "Available", // TODO: Get status when defined + photo: + photoUrlString && TeamMember.isValidPhotoUrl(photoUrlString) + ? new URL(photoUrlString) + : undefined, + teamRoles: undefined, + workPosition: undefined, // TODO: Get workPosition when defined + }); + + const teamRoles = getTeamMemberIncidentManagementTeamRoles( + teamMember, + events, + dataElementRoles + ); + + return new TeamMember({ + ...teamMember, + teamRoles: teamRoles.length > 0 ? teamRoles : undefined, + }); + } + + saveIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData { + return this.saveOrDeleteIncidentManagementTeamMember( + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + "CREATE_AND_UPDATE" + ); + } + + deleteIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData { + return this.saveOrDeleteIncidentManagementTeamMember( + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + "DELETE" + ); + } + + private getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId: Id): FutureData<{ + dataElementRoles: D2DataElement[]; + events: D2TrackerEvent[]; + }> { + return Future.joinObj( + { + dataElementRoles: apiToFuture( + this.api.models.dataElements.get({ + fields: dataElementFields, + paging: false, + filter: { + id: { + in: Object.values( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS + ), + }, + }, + }) + ), + events: apiToFuture( + this.api.tracker.events.get({ + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + trackedEntity: diseaseOutbreakId, + programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + fields: { + dataValues: { + dataElement: { id: true, code: true }, + value: true, + }, + trackedEntity: true, + event: true, + }, + }) + ), + }, + { concurrency: 2 } + ).flatMap(({ dataElementRoles, events }) => { + return Future.success({ + dataElementRoles: dataElementRoles.objects, + events: events.instances, + }); + }); + } + + private saveOrDeleteIncidentManagementTeamMember( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id, + importStrategy: "CREATE_AND_UPDATE" | "DELETE" + ): FutureData { + return getProgramStage( + this.api, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID + ).flatMap(incidentManagementTeamBuilderResponse => { + const incidentManagementTeamBuilderDataElements = + incidentManagementTeamBuilderResponse.objects[0]?.programStageDataElements; + + if (!incidentManagementTeamBuilderDataElements) + return Future.error( + new Error(`Incident Management Team Builder Program Stage metadata not found`) + ); + + return apiToFuture( + this.api.tracker.enrollments.get({ + fields: { + enrollment: true, + }, + trackedEntity: diseaseOutbreakId, + enrolledBefore: new Date().toISOString(), + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + }) + ).flatMap(enrollmentResponse => { + const enrollmentId = enrollmentResponse.instances[0]?.enrollment; + if (!enrollmentId) { + return Future.error(new Error(`Enrollment not found for Disease Outbreak`)); + } + const d2Event: D2TrackerEvent = mapIncidentManagementTeamMemberToD2Event( + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + enrollmentId, + incidentManagementTeamBuilderDataElements + ); + + return apiToFuture( + this.api.tracker.post({ importStrategy: importStrategy }, { events: [d2Event] }) + ).flatMap(saveResponse => { + if (saveResponse.status === "ERROR" || !diseaseOutbreakId) { + return Future.error( + new Error(`Error with Incident Management Team Member`) + ); + } else { + return Future.success(undefined); + } + }); + }); + }); + } +} + +const d2UserFields = { + id: true, + name: true, + email: true, + phoneNumber: true, + username: true, + avatar: true, +} as const; + +type D2User = MetadataPick<{ + users: { fields: typeof d2UserFields }; +}>["users"][number]; + +type D2UserFix = D2User & { username: string }; + +const dataElementFields = { + id: true, + code: true, + name: true, +} as const; + +export type D2DataElement = MetadataPick<{ + dataElements: { fields: typeof dataElementFields }; +}>["dataElements"][number]; diff --git a/src/data/repositories/RoleD2Repository.ts b/src/data/repositories/RoleD2Repository.ts new file mode 100644 index 00000000..90584f6c --- /dev/null +++ b/src/data/repositories/RoleD2Repository.ts @@ -0,0 +1,52 @@ +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { apiToFuture, FutureData } from "../api-futures"; +import { assertOrError } from "./utils/AssertOrError"; +import { Future } from "../../domain/entities/generic/Future"; +import { Role } from "../../domain/entities/incident-management-team/Role"; +import { RoleRepository } from "../../domain/repositories/RoleRepository"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; + +export class RoleD2Repository implements RoleRepository { + constructor(private api: D2Api) {} + + getAll(): FutureData { + return apiToFuture( + this.api.models.dataElements.get({ + fields: dataElementFields, + paging: false, + filter: { + id: { in: Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS) }, + }, + }) + ) + .flatMap(response => assertOrError(response.objects, `Roles not found`)) + .flatMap(d2DataElementRoles => { + if (d2DataElementRoles.length === 0) + return Future.error(new Error(`Roles not found`)); + else + return Future.success( + d2DataElementRoles.map(d2DataElementRole => + this.mapDataElementToRole(d2DataElementRole) + ) + ); + }); + } + + private mapDataElementToRole(d2DataElementRole: D2DataElement): Role { + return { + id: d2DataElementRole.id, + code: d2DataElementRole.code, + name: d2DataElementRole.name, + }; + } +} + +const dataElementFields = { + id: true, + code: true, + name: true, +} as const; + +type D2DataElement = MetadataPick<{ + dataElements: { fields: typeof dataElementFields }; +}>["dataElements"][number]; diff --git a/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts new file mode 100644 index 00000000..c43a079d --- /dev/null +++ b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts @@ -0,0 +1,47 @@ +import { GetValue } from "../../../utils/ts-utils"; + +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS = { + incidentManagerRole: "fnZ7EcG5CCV", + caseManagementRole: "Ci2TwQIVR2x", + ipcUnitLeadRole: "NARFizS9nsk", + labUnitLeadRole: "SsXKTkPrJt9", + operationalSectionLeadRole: "lO197QfYLBc", + surveillanceUnitLeadRole: "EnmRCZYjSV6", + vaccineUnitRole: "RMqPVOnz8ja", +} as const; + +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS = { + teamMemberAssigned: "iodfsSspCov", + reportsToUsername: "TFIPHJyXN6H", + incidentManagerRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + caseManagementRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole, + ipcUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole, + labUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole, + operationalSectionLeadRole: + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole, + surveillanceUnitLeadRole: + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole, + vaccineUnitRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole, +} as const; + +export const incidentManagementTeamBuilderCodes = { + teamMemberAssigned: "RTSL_ZEB_DET_IMB_TMA", + reportsToUsername: "RTSL_ZEB_DET_IMB_REPORTS", + incidentManagerRole: "RTSL_ZEB_DET_IMB_INCIDENT_MANAGER", + caseManagementRole: "RTSL_ZEB_DET_IMB_CASE_MANAGMENT", + ipcUnitLeadRole: "RTSL_ZEB_DET_IMB_IPC_LEAD", + labUnitLeadRole: "RTSL_ZEB_DET_IMB_LAB_LEAD", + operationalSectionLeadRole: "RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD", + surveillanceUnitLeadRole: "RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD", + vaccineUnitRole: "RTSL_ZEB_DET_IMB_VACCINE_UNIT", +} as const; + +export type IncidentManagementTeamBuilderCodes = GetValue< + typeof incidentManagementTeamBuilderCodes +>; + +export function isStringInIncidentManagementTeamBuilderCodes( + code: string +): code is IncidentManagementTeamBuilderCodes { + return (Object.values(incidentManagementTeamBuilderCodes) as string[]).includes(code); +} diff --git a/src/data/repositories/test/IncidentManagementTeamTestRepository.ts b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts new file mode 100644 index 00000000..ed2db170 --- /dev/null +++ b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts @@ -0,0 +1,55 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; +import { Id } from "../../../domain/entities/Ref"; +import { IncidentManagementTeamRepository } from "../../../domain/repositories/IncidentManagementTeamRepository"; +import { Maybe } from "../../../utils/ts-utils"; +import { FutureData } from "../../api-futures"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../consts/IncidentManagementTeamBuilderConstants"; + +export class IncidentManagementTeamTestRepository implements IncidentManagementTeamRepository { + get( + _diseaseOutbreakId: Id, + _teamMembers: TeamMember[] + ): FutureData> { + return Future.success(undefined); + } + + saveIncidentManagementTeamMemberRole( + _teamMemberRole: TeamRole, + _incidentManagementTeamMember: TeamMember, + _diseaseOutbreakId: Id + ): FutureData { + return Future.success(undefined); + } + + deleteIncidentManagementTeamMemberRole( + _teamMemberRole: TeamRole, + _incidentManagementTeamMember: TeamMember, + _diseaseOutbreakId: Id + ): FutureData { + return Future.success(undefined); + } + + getIncidentManagementTeamMember(username: Id, _diseaseOutbreakId: Id): FutureData { + const teamMember: TeamMember = new TeamMember({ + id: username, + username: username, + name: `Team Member Name ${username}`, + email: `email@email.com`, + phone: `121-1234`, + teamRoles: [ + { + id: "role", + name: "role", + roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + reportsToUsername: "reportsToUsername", + }, + ], + status: "Available", + photo: new URL("https://www.example.com"), + workPosition: "workPosition", + }); + return Future.success(teamMember); + } +} diff --git a/src/data/repositories/test/RoleTestRepository.ts b/src/data/repositories/test/RoleTestRepository.ts new file mode 100644 index 00000000..ad8ef9ca --- /dev/null +++ b/src/data/repositories/test/RoleTestRepository.ts @@ -0,0 +1,11 @@ +import { Future } from "../../../domain/entities/generic/Future"; +import { Role } from "../../../domain/entities/incident-management-team/Role"; +import { RoleRepository } from "../../../domain/repositories/RoleRepository"; +import { FutureData } from "../../api-futures"; + +export class RoleTestRepository implements RoleRepository { + getAll(): FutureData { + const roles: Role[] = []; + return Future.success(roles); + } +} diff --git a/src/data/repositories/utils/IncidentManagementTeamMapper.ts b/src/data/repositories/utils/IncidentManagementTeamMapper.ts new file mode 100644 index 00000000..3bafec72 --- /dev/null +++ b/src/data/repositories/utils/IncidentManagementTeamMapper.ts @@ -0,0 +1,202 @@ +import { D2TrackerEvent, DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; + +import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { getPopulatedDataElement, getValueById } from "./helpers"; +import { + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + RTSL_ZEBRA_ORG_UNIT_ID, + RTSL_ZEBRA_PROGRAM_ID, +} from "../consts/DiseaseOutbreakConstants"; +import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; +import { Maybe } from "../../../utils/ts-utils"; +import { D2DataElement } from "../IncidentManagementTeamD2Repository"; +import _c from "../../../domain/entities/generic/Collection"; +import { Id } from "../../../domain/entities/Ref"; +import { SelectedPick } from "@eyeseetea/d2-api/api"; +import { D2DataElementSchema } from "@eyeseetea/d2-api/2.36"; +import { + IncidentManagementTeamBuilderCodes, + isStringInIncidentManagementTeamBuilderCodes, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, +} from "../consts/IncidentManagementTeamBuilderConstants"; + +export function mapD2EventsToIncidentManagementTeam( + events: D2TrackerEvent[], + dataElementRoles: D2DataElement[], + teamMembers: TeamMember[] +): Maybe { + const teamHierarchy: TeamMember[] = teamMembers.reduce( + (acc: TeamMember[], teamMember: TeamMember) => { + const memberRoleEvents = events.filter(event => { + const teamMemberAssignedUsername = getValueById( + event.dataValues, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + ); + return teamMemberAssignedUsername === teamMember.username; + }); + + if (memberRoleEvents.length === 0) { + return acc; + } else { + const teamRoles = getTeamMemberIncidentManagementTeamRoles( + teamMember, + memberRoleEvents, + dataElementRoles + ); + return teamRoles.length === 0 + ? acc + : [...acc, new TeamMember({ ...teamMember, teamRoles: teamRoles })]; + } + }, + [] + ); + + return new IncidentManagementTeam({ + teamHierarchy: teamHierarchy, + }); +} + +export function getTeamMemberIncidentManagementTeamRoles( + teamMemberAssigned: TeamMember, + events: D2TrackerEvent[], + dataElementRoles: D2DataElement[] +): TeamRole[] { + return events.reduce((acc: TeamRole[], event: D2TrackerEvent) => { + if ( + teamMemberAssigned.username === + getValueById( + event.dataValues, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + ) + ) { + const teamRole = getTeamRole(event.event, event.dataValues, dataElementRoles); + return teamRole ? [...acc, teamRole] : acc; + } + return acc; + }, []); +} + +function getTeamRole( + eventId: Id, + dataValues: DataValue[], + dataElementRoles: D2DataElement[] +): Maybe { + const roleIds = Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS); + + const selectedRoleId = roleIds.find(roleId => { + const role = getValueById(dataValues, roleId); + return role === "true"; + }); + + const roleDataElement = dataElementRoles.find(dataElement => dataElement.id === selectedRoleId); + + const reportsToUsername = getValueById( + dataValues, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.reportsToUsername + ); + + if (selectedRoleId && roleDataElement) { + return { + id: eventId, + roleId: selectedRoleId, + name: roleDataElement?.name, + reportsToUsername: reportsToUsername, + }; + } +} + +type D2ProgramStageDataElementsMetadata = { + dataElement: SelectedPick< + D2DataElementSchema, + { + id: true; + valueType: true; + code: true; + } + >; +}; + +export function mapIncidentManagementTeamMemberToD2Event( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + teiId: Id, + enrollmentId: Id, + programStageDataElementsMetadata: D2ProgramStageDataElementsMetadata[] +): D2TrackerEvent { + const dataElementValues: Record = + getValueFromIncidentManagementTeamMember( + incidentManagementTeamMember.username, + teamMemberRole + ); + + const dataValues: DataValue[] = programStageDataElementsMetadata.map(programStage => { + if (!isStringInIncidentManagementTeamBuilderCodes(programStage.dataElement.code)) { + throw new Error("DataElement code not found in IncidentManagementTeamBuilderCodes"); + } + const typedCode: IncidentManagementTeamBuilderCodes = programStage.dataElement.code; + return getPopulatedDataElement(programStage.dataElement.id, dataElementValues[typedCode]); + }); + + const d2IncidentManagementTeam: D2TrackerEvent = { + event: teamMemberRole.id ?? "", + status: "ACTIVE", + program: RTSL_ZEBRA_PROGRAM_ID, + programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + enrollment: enrollmentId, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + occurredAt: new Date().toISOString(), + dataValues: dataValues, + trackedEntity: teiId, + }; + + return d2IncidentManagementTeam; +} + +export function getValueFromIncidentManagementTeamMember( + incidentManagementTeamMemberUsername: string, + teamRoleAssigned: TeamRole +): Record { + const checkRoleSelected = (roleId: string): boolean => + (teamRoleAssigned?.roleId || "") === roleId; + + return { + RTSL_ZEB_DET_IMB_TMA: incidentManagementTeamMemberUsername, + RTSL_ZEB_DET_IMB_REPORTS: teamRoleAssigned?.reportsToUsername ?? "", + RTSL_ZEB_DET_IMB_INCIDENT_MANAGER: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_CASE_MANAGMENT: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_IPC_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_LAB_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole + ) + ? "true" + : "", + RTSL_ZEB_DET_IMB_VACCINE_UNIT: checkRoleSelected( + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole + ) + ? "true" + : "", + }; +} diff --git a/src/data/repositories/utils/helpers.ts b/src/data/repositories/utils/helpers.ts new file mode 100644 index 00000000..edb6fd18 --- /dev/null +++ b/src/data/repositories/utils/helpers.ts @@ -0,0 +1,23 @@ +import { DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; +import { Maybe } from "../../../utils/ts-utils"; +import { Id } from "../../../domain/entities/Ref"; + +export function getValueById(dataValues: DataValue[], dataElement: string): Maybe { + return dataValues.find(dataValue => dataValue.dataElement === dataElement)?.value; +} + +export function getDataValueById(dataValues: DataValue[], dataElement: string): Maybe { + return dataValues.find(dataValue => dataValue.dataElement === dataElement); +} + +export function getPopulatedDataElement(dataElement: Id, value: Maybe): DataValue { + const populatedDataElement: DataValue = { + dataElement: dataElement, + value: value ?? "", + updatedAt: new Date().toISOString(), + storedBy: "", + createdAt: new Date().toISOString(), + providedElsewhere: false, + }; + return populatedDataElement; +} diff --git a/src/domain/entities/incident-management-team/Role.ts b/src/domain/entities/incident-management-team/Role.ts new file mode 100644 index 00000000..f9bb7b9a --- /dev/null +++ b/src/domain/entities/incident-management-team/Role.ts @@ -0,0 +1,3 @@ +import { CodedNamedRef } from "../Ref"; + +export type Role = CodedNamedRef; diff --git a/src/domain/repositories/IncidentManagementTeamRepository.ts b/src/domain/repositories/IncidentManagementTeamRepository.ts new file mode 100644 index 00000000..fe710da6 --- /dev/null +++ b/src/domain/repositories/IncidentManagementTeamRepository.ts @@ -0,0 +1,23 @@ +import { FutureData } from "../../data/api-futures"; +import { Maybe } from "../../utils/ts-utils"; +import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; +import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; +import { Id } from "../entities/Ref"; + +export interface IncidentManagementTeamRepository { + get( + diseaseOutbreakId: Id, + teamMembers: TeamMember[] + ): FutureData>; + saveIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData; + deleteIncidentManagementTeamMemberRole( + teamMemberRole: TeamRole, + incidentManagementTeamMember: TeamMember, + diseaseOutbreakId: Id + ): FutureData; + getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData; +} diff --git a/src/domain/repositories/RoleRepository.ts b/src/domain/repositories/RoleRepository.ts new file mode 100644 index 00000000..9720a272 --- /dev/null +++ b/src/domain/repositories/RoleRepository.ts @@ -0,0 +1,6 @@ +import { FutureData } from "../../data/api-futures"; +import { Role } from "../entities/incident-management-team/Role"; + +export interface RoleRepository { + getAll(): FutureData; +} diff --git a/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts new file mode 100644 index 00000000..fe5c9823 --- /dev/null +++ b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts @@ -0,0 +1,24 @@ +import { FutureData } from "../../data/api-futures"; +import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; +import { Id } from "../entities/Ref"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; + +export class DeleteIncidentManagementTeamMemberRoleUseCase { + constructor( + private options: { + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } + ) {} + + public execute( + teamMemberRole: TeamRole, + incidentManagementTeam: TeamMember, + diseaseOutbreakId: Id + ): FutureData { + return this.options.incidentManagementTeamRepository.deleteIncidentManagementTeamMemberRole( + teamMemberRole, + incidentManagementTeam, + diseaseOutbreakId + ); + } +} diff --git a/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts new file mode 100644 index 00000000..2f994a94 --- /dev/null +++ b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts @@ -0,0 +1,24 @@ +import { FutureData } from "../../data/api-futures"; +import { Maybe } from "../../utils/ts-utils"; +import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; +import { Id } from "../entities/Ref"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; +import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; + +export class GetIncidentManagementTeamByIdUseCase { + constructor( + private options: { + teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } + ) {} + + public execute(diseaseOutbreakEventId: Id): FutureData> { + return getIncidentManagementTeamById( + diseaseOutbreakEventId, + this.options.incidentManagementTeamRepository, + this.options.teamMemberRepository + ); + } +} diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts new file mode 100644 index 00000000..be105f1f --- /dev/null +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts @@ -0,0 +1,16 @@ +import { FutureData } from "../../../../data/api-futures"; +import { Maybe } from "../../../../utils/ts-utils"; +import { IncidentManagementTeam } from "../../../entities/incident-management-team/IncidentManagementTeam"; +import { Id } from "../../../entities/Ref"; +import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; + +export function getIncidentManagementTeamById( + diseaseOutbreakId: Id, + incidentManagementTeamRepository: IncidentManagementTeamRepository, + teamMemberRepository: TeamMemberRepository +): FutureData> { + return teamMemberRepository.getAll().flatMap(teamMembers => { + return incidentManagementTeamRepository.get(diseaseOutbreakId, teamMembers); + }); +} diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts new file mode 100644 index 00000000..4b39c6d4 --- /dev/null +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts @@ -0,0 +1,66 @@ +import { FutureData } from "../../../../data/api-futures"; +import { incidentManagementTeamBuilderCodes } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { Maybe } from "../../../../utils/ts-utils"; +import { SECTION_IDS } from "../../../../webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; +import { IncidentManagementTeamMemberFormData } from "../../../entities/ConfigurableForm"; +import { DiseaseOutbreakEvent } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../../../entities/generic/Future"; +import { Id } from "../../../entities/Ref"; +import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../../../repositories/RoleRepository"; +import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; +import { getIncidentManagementTeamById } from "./GetIncidentManagementTeamById"; + +export function getIncidentManagementTeamWithOptions( + incidentManagementTeamRoleId: Maybe, + eventTrackerDetails: DiseaseOutbreakEvent, + repositories: { + roleRepository: RoleRepository; + teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } +): FutureData { + return Future.joinObj({ + roles: repositories.roleRepository.getAll(), + teamMembers: repositories.teamMemberRepository.getForIncidentManagementTeamMembers(), + incidentManagers: repositories.teamMemberRepository.getIncidentManagers(), + incidentManagementTeam: getIncidentManagementTeamById( + eventTrackerDetails.id, + repositories.incidentManagementTeamRepository, + repositories.teamMemberRepository + ), + }).flatMap(({ roles, teamMembers, incidentManagers, incidentManagementTeam }) => { + const teamMemberSelected = incidentManagementTeam?.teamHierarchy.find(teamMember => + teamMember.teamRoles?.some(teamRole => teamRole.id === incidentManagementTeamRoleId) + ); + + const incidentManagementTeamMemberFormData: IncidentManagementTeamMemberFormData = { + type: "incident-management-team-member-assignment", + eventTrackerDetails: eventTrackerDetails, + entity: teamMemberSelected, + incidentManagementTeamRoleId: incidentManagementTeamRoleId, + currentIncidentManagementTeam: incidentManagementTeam, + options: { + roles: roles, + teamMembers: teamMembers.filter(teamMembers => teamMembers.status === "Available"), + incidentManagers: incidentManagers.filter( + teamMembers => teamMembers.status === "Available" + ), + }, + labels: { + errors: { + field_is_required: "This field is required", + }, + }, + rules: [ + { + type: "disableFieldOptionWithSameFieldValue", + fieldId: incidentManagementTeamBuilderCodes.teamMemberAssigned, + fieldIdsToDisableOption: [incidentManagementTeamBuilderCodes.reportsToUsername], + sectionsWithFieldsToDisableOption: [SECTION_IDS.reportsTo], + }, + ], + }; + return Future.success(incidentManagementTeamMemberFormData); + }); +} diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx new file mode 100644 index 00000000..ca04b388 --- /dev/null +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx @@ -0,0 +1,138 @@ +import { TreeItem as TreeItemMUI } from "@material-ui/lab"; +import React from "react"; +import styled from "styled-components"; +import { IconUser24 } from "@dhis2/ui"; + +import { Maybe } from "../../../utils/ts-utils"; +import { Checkbox } from "../checkbox/Checkbox"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { TeamMemberProfile } from "./TeamMemberProfile"; + +type IMTeamHierarchyItemProps = { + nodeId: string; + teamRole: string; + member: Maybe; + selected: boolean; + disabled?: boolean; + onSelectedChange: (nodeId: string, selected: boolean) => void; + children?: React.ReactNode; + diseaseOutbreakEventName: string; +}; + +export const IMTeamHierarchyItem: React.FC = React.memo(props => { + const { + nodeId, + teamRole, + member, + disabled = false, + onSelectedChange, + selected, + children, + diseaseOutbreakEventName, + } = props; + + const [openMemberProfile, setOpenMemberProfile] = React.useState(false); + + const onCheckboxChange = React.useCallback( + (isChecked: boolean) => { + !disabled && onSelectedChange(nodeId, isChecked); + }, + [disabled, nodeId, onSelectedChange] + ); + + const onTeamRoleClick = React.useCallback( + (event: React.MouseEvent) => { + event.preventDefault(); + !disabled && onSelectedChange(nodeId, !selected); + }, + [disabled, nodeId, onSelectedChange, selected] + ); + + const onMemberClick = React.useCallback( + (event: React.MouseEvent) => { + if (member) { + event.preventDefault(); + setOpenMemberProfile(true); + } + }, + [member] + ); + + return ( + <> + + + + + + + {teamRole}: + + + {member ? member.name : "TBD"} + + + + } + > + {children} + + + {member && ( + + )} + + ); +}); + +const StyledIMTeamHierarchyItem = styled(TreeItemMUI)` + .MuiTreeItem-label { + padding-left: 0; + height: 30px; + span.MuiButtonBase-root.MuiCheckbox-root { + padding: 9px 2px; + } + } +`; + +const LabelWrapper = styled.div` + display: flex; + align-items: center; +`; + +const RoleAndMemberWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; + svg { + color: ${props => props.theme.palette.common.grey700}; + height: 20px; + width: 20px; + } +`; + +const RoleWrapper = styled.div` + font-weight: 700; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; + +const MemberWrapper = styled.div` + font-weight: 400; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx new file mode 100644 index 00000000..a2ce3b9b --- /dev/null +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx @@ -0,0 +1,83 @@ +import { TreeView as TreeViewMUI } from "@material-ui/lab"; +import React from "react"; +import styled from "styled-components"; +import { ArrowDropDown, ArrowRight } from "@material-ui/icons"; + +import { Maybe } from "../../../utils/ts-utils"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { IMTeamHierarchyItem } from "./IMTeamHierarchyItem"; +import { Id } from "../../../domain/entities/Ref"; + +export type IMTeamHierarchyOption = { + id: Id; + teamRole: string; + teamRoleId: Id; + member: Maybe; + parents: { id: Id; name: string }[]; + children: IMTeamHierarchyOption[]; +}; + +type IMTeamHierarchyViewProps = { + items: IMTeamHierarchyOption[]; + selectedItemId: Id; + onSelectedItemChange: (nodeId: Id, selected: boolean) => void; + diseaseOutbreakEventName: string; +}; + +export const IMTeamHierarchyView: React.FC = React.memo(props => { + const { onSelectedItemChange, items, selectedItemId, diseaseOutbreakEventName } = props; + + return ( + + } + defaultExpandIcon={} + > + {items.map(item => ( + + {item.children && + item.children.map(child => ( + + ))} + + ))} + + + ); +}); + +const IMTeamHierarchyViewContainer = styled.div` + border: 1px solid ${props => props.theme.palette.common.grey500}; + background-color: ${props => props.theme.palette.common.white}; + padding: 8px; + @media (max-width: 800px) { + } +`; + +const StyledIMTeamHierarchyView = styled(TreeViewMUI)` + .MuiTreeItem-group { + margin-left: 8px; + border-left: 1px solid ${props => props.theme.palette.common.grey400}; + } + + .MuiTreeItem-content { + align-items: baseline; + } +`; diff --git a/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx new file mode 100644 index 00000000..172e9936 --- /dev/null +++ b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx @@ -0,0 +1,80 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; +import { Link } from "@material-ui/core"; + +import i18n from "../../../utils/i18n"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { ProfileModal } from "../profile-modal/ProfileModal"; + +type TeamMemberProfileProps = { + open: boolean; + setOpen: (open: boolean) => void; + member: TeamMember; + diseaseOutbreakEventName: string; +}; + +export const TeamMemberProfile: React.FC = React.memo(props => { + const { open, setOpen, member, diseaseOutbreakEventName } = props; + + const teamRolesNames = useMemo( + () => member.teamRoles?.map(role => role.name).join(", "), + [member.teamRoles] + ); + + return ( + setOpen(false)} + name={member.name} + src={member.photo?.toString()} + alt={member.photo ? `Photo of ${member.name}` : undefined} + > + + {member.phone} + + {member.email} + + {teamRolesNames && {teamRolesNames}} + + + {i18n.t("Currently assigned:", { nsSeparator: false })} + + {diseaseOutbreakEventName} + + + + ); +}); + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const AssignContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const TextBold = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 0.875rem; + font-weight: 700; +`; + +const Text = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 0.875rem; + font-weight: 400; +`; + +const StyledLink = styled(Link)` + &.MuiTypography-colorPrimary { + font-size: 0.875rem; + font-weight: 400; + text-decoration: underline; + color: ${props => props.theme.palette.common.black}; + } +`; diff --git a/src/webapp/components/simple-modal/SimpleModal.tsx b/src/webapp/components/simple-modal/SimpleModal.tsx new file mode 100644 index 00000000..a8706b33 --- /dev/null +++ b/src/webapp/components/simple-modal/SimpleModal.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { Modal, CardContent, Card } from "@material-ui/core"; +import styled from "styled-components"; + +import i18n from "../../../utils/i18n"; +import { Button } from "../button/Button"; + +type SimpleModalProps = { + title: string; + children: React.ReactNode; + footerButtons?: React.ReactNode; + open: boolean; + onClose: () => void; + closeLabel?: string; +}; + +export const SimpleModal: React.FC = React.memo( + ({ children, footerButtons, closeLabel, open = false, onClose, title }) => { + return ( + + + {title} + + + {children} + + +
+ {footerButtons ?? null} + +
+
+
+ ); + } +); + +const Content = styled.div` + display: flex; + @media (max-width: 700px) { + flex-direction: column; + } +`; + +const Title = styled.span` + color: ${props => props.theme.palette.common.black}; + font-size: 1.25rem; + font-weight: 500; +`; + +const Footer = styled.div` + display: flex; + margin-block-start: 16px; + gap: 8px; +`; + +const StyledCard = styled(Card)` + width: 500px; + @media (max-width: 700px) { + width: 300px; + } + display: flex; + flex-direction: column; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 24px; + box-shadow: 0px 8px 16px rgba(0, 0, 0, 0.1); +`; + +const StyledCardContent = styled(CardContent)` + width: 100%; +`; diff --git a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts new file mode 100644 index 00000000..96b5643a --- /dev/null +++ b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts @@ -0,0 +1,133 @@ +import { IncidentManagementTeamMemberFormData } from "../../../../domain/entities/ConfigurableForm"; +import { FormState } from "../../../components/form/FormState"; +import { mapTeamMemberToUser, mapToPresentationOptions } from "../mapEntityToFormState"; +import { Option as UIOption } from "../../../components/utils/option"; +import { User } from "../../../components/user-selector/UserSelector"; +import { + incidentManagementTeamBuilderCodes, + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, +} from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; + +export const TEAM_ROLE_FIELD_ID = "team-role-field"; +export const SECTION_IDS = { + teamRole: "team-role-section", + teamMemberAssigned: `${incidentManagementTeamBuilderCodes.teamMemberAssigned}-section`, + reportsTo: `${incidentManagementTeamBuilderCodes.reportsToUsername}-section`, +}; + +export function mapIncidentManagementTeamMemberToInitialFormState( + formData: IncidentManagementTeamMemberFormData +): FormState { + const { + entity: incidentManagementTeamMember, + eventTrackerDetails, + options, + incidentManagementTeamRoleId, + currentIncidentManagementTeam, + } = formData; + + const { roles, teamMembers, incidentManagers } = options; + + const roleOptions: UIOption[] = mapToPresentationOptions(roles); + const roleOptionsWithoutIncidentManager: UIOption[] = mapToPresentationOptions( + roles.filter( + role => + role.id !== RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ) + ); + const teamMemberOptions: User[] = teamMembers.map(tm => mapTeamMemberToUser(tm)); + const incidentManagerOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); + const currentIncidentManagementTeamOptions: User[] = ( + currentIncidentManagementTeam?.teamHierarchy || [] + ).map(tm => mapTeamMemberToUser(tm)); + const teamRoleToAssing = incidentManagementTeamMember?.teamRoles?.find( + teamRole => teamRole.id === incidentManagementTeamRoleId + ); + + return { + id: incidentManagementTeamMember?.id || "", + title: "Incident Management Team Builder", + subtitle: eventTrackerDetails.name, + saveButtonLabel: "Save Assignment", + isValid: false, + sections: [ + { + title: "Role", + id: SECTION_IDS.teamRole, + isVisible: true, + required: true, + fields: [ + { + id: TEAM_ROLE_FIELD_ID, + placeholder: "Select a role", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: + teamRoleToAssing?.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ? roleOptions + : roleOptionsWithoutIncidentManager, + value: teamRoleToAssing?.roleId || "", + required: true, + showIsRequired: true, + disabled: + teamRoleToAssing?.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + }, + ], + }, + { + title: "Team member assigned", + id: SECTION_IDS.teamMemberAssigned, + isVisible: true, + required: true, + fields: [ + { + id: incidentManagementTeamBuilderCodes.teamMemberAssigned, + placeholder: "Select a team member", + helperText: "Only available team members are shown", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: + teamRoleToAssing?.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ? incidentManagerOptions + : teamMemberOptions, + value: incidentManagementTeamMember?.username || "", + required: true, + showIsRequired: true, + disabled: false, + }, + ], + }, + { + title: "Reports to...", + id: SECTION_IDS.reportsTo, + isVisible: true, + required: false, + fields: [ + { + id: incidentManagementTeamBuilderCodes.reportsToUsername, + placeholder: "Select a team member", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: currentIncidentManagementTeamOptions.map(user => ({ + ...user, + disabled: user.value === incidentManagementTeamMember?.username, + })), + value: teamRoleToAssing?.reportsToUsername || "", + required: false, + showIsRequired: false, + disabled: false, + }, + ], + }, + ], + }; +} diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts new file mode 100644 index 00000000..576a3ceb --- /dev/null +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -0,0 +1,264 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Id } from "../../../domain/entities/Ref"; +import { Maybe } from "../../../utils/ts-utils"; +import { useAppContext } from "../../contexts/app-context"; +import { getDateAsLocaleDateTimeString } from "../../../data/repositories/utils/DateTimeHelper"; +import { User } from "../../components/user-selector/UserSelector"; +import { mapTeamMemberToUser } from "../form-page/mapEntityToFormState"; +import { IMTeamHierarchyOption } from "../../components/im-team-hierarchy/IMTeamHierarchyView"; +import { RouteName, useRoutes } from "../../hooks/useRoutes"; +import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; + +type GlobalMessage = { + text: string; + type: "warning" | "success" | "error"; +}; + +type State = { + globalMessage: Maybe; + incidentManagementTeamHierarchyItems: Maybe; + selectedHierarchyItemId: string; + onSelectHierarchyItem: (nodeId: string, selected: boolean) => void; + goToIncidentManagementTeamRole: () => void; + onDeleteIncidentManagementTeamMember: () => void; + incidentManagerUser: Maybe; + lastUpdated: string; + openDeleteModalData: IMTeamHierarchyOption | undefined; + onOpenDeleteModalData: (selectedHierarchyItemId: Id | undefined) => void; + disableDeletion: boolean; +}; + +export function useIMTeamBuilder(id: Id): State { + const { compositionRoot } = useAppContext(); + const { goTo } = useRoutes(); + + const [globalMessage, setGlobalMessage] = useState>(); + const [incidentManagementTeamHierarchyItems, setIncidentManagementTeamHierarchyItems] = + useState(); + const [incidentManagementTeam, setIncidentManagementTeam] = useState< + IncidentManagementTeam | undefined + >(); + const [selectedHierarchyItemId, setSelectedHierarchyItemId] = useState(""); + const [disableDeletion, setDisableDeletion] = useState(false); + const [openDeleteModalData, setOpenDeleteModalData] = useState< + IMTeamHierarchyOption | undefined + >(undefined); + + useEffect(() => { + compositionRoot.incidentManagementTeam.get.execute(id).run( + incidentManagementTeam => { + setIncidentManagementTeam(incidentManagementTeam); + setIncidentManagementTeamHierarchyItems( + mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( + incidentManagementTeam + ) + ); + }, + err => { + console.debug(err); + setGlobalMessage({ + text: `Error loading current Incident Management Team`, + type: "error", + }); + } + ); + }, [compositionRoot.incidentManagementTeam.get, id]); + + const goToIncidentManagementTeamRole = useCallback(() => { + if (selectedHierarchyItemId) { + goTo(RouteName.EDIT_FORM, { + formType: "incident-management-team-member-assignment", + id: selectedHierarchyItemId, + }); + } else { + goTo(RouteName.CREATE_FORM, { + formType: "incident-management-team-member-assignment", + }); + } + }, [goTo, selectedHierarchyItemId]); + + const onSelectHierarchyItem = useCallback( + (nodeId: string, selected: boolean) => { + const selection = selected ? nodeId : ""; + const incidentManagementTeamItemSelected = selection + ? incidentManagementTeamHierarchyItems?.find(item => item.id === selection) + : undefined; + + const isIncidentManagerRoleSelected = + incidentManagementTeamItemSelected?.teamRoleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole; + + setSelectedHierarchyItemId(selection); + setDisableDeletion(isIncidentManagerRoleSelected); + }, + [incidentManagementTeamHierarchyItems] + ); + + const onOpenDeleteModalData = useCallback( + (selectedHierarchyItemId: Id | undefined) => { + if (!selectedHierarchyItemId) { + setOpenDeleteModalData(undefined); + } else { + const incidentManagementTeamItem = incidentManagementTeamHierarchyItems?.find( + item => item.id === selectedHierarchyItemId + ); + + if (incidentManagementTeamItem) { + setOpenDeleteModalData(incidentManagementTeamItem); + } + } + }, + [incidentManagementTeamHierarchyItems] + ); + + const onDeleteIncidentManagementTeamMember = useCallback(() => { + if (disableDeletion) return; + + const teamMember = incidentManagementTeamHierarchyItems?.find( + item => item.id === selectedHierarchyItemId + )?.member; + + const teamRoleToDelete = teamMember?.teamRoles?.find( + teamRole => teamRole.id === selectedHierarchyItemId + ); + + if (teamMember && teamRoleToDelete) { + compositionRoot.incidentManagementTeam.deleteIncidentManagementTeamMemberRole + .execute(teamRoleToDelete, teamMember, id) + .run( + () => { + setGlobalMessage({ + text: `${teamMember.name} deleted from Incident Management Team`, + type: "success", + }); + }, + err => { + console.debug(err); + setGlobalMessage({ + text: `Error deleting ${teamMember.name} from Incident Management Team`, + type: "error", + }); + } + ); + } else { + setGlobalMessage({ + text: `Error deleting team member from Incident Management Team`, + type: "error", + }); + } + }, [ + compositionRoot.incidentManagementTeam.deleteIncidentManagementTeamMemberRole, + disableDeletion, + id, + incidentManagementTeamHierarchyItems, + selectedHierarchyItemId, + ]); + + const incidentManagerUser = useMemo(() => { + const incidentManagerTeamMember = incidentManagementTeam?.teamHierarchy.find(member => { + return member.teamRoles?.some( + role => + role.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ); + }); + if (incidentManagerTeamMember) { + return mapTeamMemberToUser(incidentManagerTeamMember); + } + }, [incidentManagementTeam?.teamHierarchy]); + + const lastUpdated = getDateAsLocaleDateTimeString(new Date()); //TO DO : Fetch sync time from datastore once implemented + + return { + globalMessage, + incidentManagementTeamHierarchyItems, + selectedHierarchyItemId, + onSelectHierarchyItem, + goToIncidentManagementTeamRole, + incidentManagerUser, + lastUpdated, + onDeleteIncidentManagementTeamMember, + openDeleteModalData, + onOpenDeleteModalData, + disableDeletion, + }; +} + +function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( + incidentManagementTeam: Maybe +): IMTeamHierarchyOption[] { + if (incidentManagementTeam?.teamHierarchy) { + const createHierarchyItem = ( + item: TeamMember, + teamRole: TeamRole + ): IMTeamHierarchyOption => ({ + id: teamRole.id, + teamRole: teamRole.name, + teamRoleId: teamRole.roleId, + member: new TeamMember({ + id: item.id, + name: item.name, + username: item.username, + phone: item.phone, + email: item.email, + status: item.status, + photo: item.photo, + teamRoles: item.teamRoles, + workPosition: item.workPosition, + }), + parents: [], + children: [], + }); + + const teamMap = incidentManagementTeam?.teamHierarchy.reduce< + Record + >((map, item) => { + const hierarchyItems = item.teamRoles?.map(teamRole => + createHierarchyItem(item, teamRole) + ); + + return !hierarchyItems || hierarchyItems?.length === 0 + ? map + : hierarchyItems.reduce( + (acc, hierarchyItem) => ({ + ...acc, + [hierarchyItem.id]: hierarchyItem, + }), + map + ); + }, {}); + + return incidentManagementTeam.teamHierarchy.reduce((acc, item) => { + return item.teamRoles + ? item.teamRoles.reduce((innerAcc, teamRole) => { + const hierarchyItem = teamMap[teamRole.id]; + if (!hierarchyItem) return innerAcc; + + const reportsToUsername = teamRole.reportsToUsername; + if (reportsToUsername) { + const parentItem = Object.values(teamMap).find( + teamItem => teamItem.member?.username === reportsToUsername + ); + + if (parentItem) { + parentItem.children = [...(parentItem.children || []), hierarchyItem]; + hierarchyItem.parents = [ + ...hierarchyItem.parents, + { id: parentItem.id, name: parentItem.teamRole }, + ]; + } + } + + return hierarchyItem.parents.length === 0 + ? [...innerAcc, hierarchyItem] + : innerAcc; + }, acc) + : acc; + }, []); + } else { + return []; + } +} From d302181483b2d66a45d329996a0566c3943e4fca Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 11:45:55 +0200 Subject: [PATCH 13/24] Add incident management team builder logic --- src/CompositionRoot.ts | 24 ++- .../repositories/TeamMemberD2Repository.ts | 8 +- .../consts/DiseaseOutbreakConstants.ts | 1 + .../test/TeamMemberTestRepository.ts | 28 +++- .../utils/RiskAssessmentMapper.ts | 17 +- src/domain/entities/ConfigurableForm.ts | 21 ++- src/domain/entities/Rule.ts | 20 ++- .../incident-management-team/TeamMember.ts | 6 +- .../repositories/TeamMemberRepository.ts | 1 + .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 48 +++--- .../usecases/GetEntityWithOptionsUseCase.ts | 18 +++ src/domain/usecases/SaveEntityUseCase.ts | 79 ++++++++- .../disease-outbreak/SaveDiseaseOutbreak.ts | 151 +++++++++++++++++- src/types/d2-ui.d.ts | 2 + src/webapp/components/form/Form.tsx | 1 - src/webapp/components/form/FormFieldsState.ts | 28 ++++ .../components/form/FormSectionsState.ts | 88 ++++++++++ .../components/form/__tests__/Form.spec.tsx | 4 +- .../layout/side-bar/SideBarContent.tsx | 17 +- .../components/user-selector/UserCard.tsx | 25 +-- .../components/user-selector/UserSelector.tsx | 1 + src/webapp/hooks/useRoutes.ts | 5 +- src/webapp/pages/form-page/FormPage.tsx | 3 +- ...pDiseaseOutbreakEventToInitialFormState.ts | 4 +- .../utils/applyRulesInFormState.ts | 24 ++- .../updateDiseaseOutbreakEventFormState.ts | 2 + .../pages/form-page/mapEntityToFormState.ts | 7 +- .../form-page/mapFormStateToEntityData.ts | 64 ++++++++ src/webapp/pages/form-page/useForm.ts | 48 +++++- .../IMTeamBuilderPage.tsx | 136 +++++++++++++++- 30 files changed, 792 insertions(+), 89 deletions(-) diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 39bed03e..dd7bf83b 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -41,6 +41,14 @@ import { AlertSyncDataStoreTestRepository } from "./data/repositories/test/Alert import { AlertSyncRepository } from "./domain/repositories/AlertSyncRepository"; import { DataStoreClient } from "./data/DataStoreClient"; import { GetTotalCardCountsUseCase } from "./domain/usecases/GetTotalCardCountsUseCase"; +import { RoleRepository } from "./domain/repositories/RoleRepository"; +import { RoleD2Repository } from "./data/repositories/RoleD2Repository"; +import { RoleTestRepository } from "./data/repositories/test/RoleTestRepository"; +import { IncidentManagementTeamTestRepository } from "./data/repositories/test/IncidentManagementTeamTestRepository"; +import { IncidentManagementTeamD2Repository } from "./data/repositories/IncidentManagementTeamD2Repository"; +import { IncidentManagementTeamRepository } from "./domain/repositories/IncidentManagementTeamRepository"; +import { GetIncidentManagementTeamByIdUseCase } from "./domain/usecases/GetIncidentManagementTeamByIdUseCase"; +import { DeleteIncidentManagementTeamMemberRoleUseCase } from "./domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase"; export type CompositionRoot = ReturnType; @@ -55,15 +63,14 @@ type Repositories = { riskAssessmentRepository: RiskAssessmentRepository; mapConfigRepository: MapConfigRepository; performanceOverviewRepository: PerformanceOverviewRepository; + roleRepository: RoleRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; }; function getCompositionRoot(repositories: Repositories) { return { getWithOptions: new GetEntityWithOptionsUseCase(repositories), - save: new SaveEntityUseCase( - repositories.diseaseOutbreakEventRepository, - repositories.riskAssessmentRepository - ), + save: new SaveEntityUseCase(repositories), users: { getCurrent: new GetCurrentUserUseCase(repositories.usersRepository), }, @@ -76,6 +83,11 @@ function getCompositionRoot(repositories: Repositories) { repositories.optionsRepository ), }, + incidentManagementTeam: { + get: new GetIncidentManagementTeamByIdUseCase(repositories), + deleteIncidentManagementTeamMemberRole: + new DeleteIncidentManagementTeamMemberRoleUseCase(repositories), + }, performanceOverview: { getPerformanceOverviewMetrics: new GetAllPerformanceOverviewMetricsUseCase( repositories @@ -105,6 +117,8 @@ export function getWebappCompositionRoot(api: D2Api) { riskAssessmentRepository: new RiskAssessmentD2Repository(api), mapConfigRepository: new MapConfigD2Repository(api), performanceOverviewRepository: new PerformanceOverviewD2Repository(api, dataStoreClient), + roleRepository: new RoleD2Repository(api), + incidentManagementTeamRepository: new IncidentManagementTeamD2Repository(api), }; return getCompositionRoot(repositories); @@ -122,6 +136,8 @@ export function getTestCompositionRoot() { riskAssessmentRepository: new RiskAssessmentTestRepository(), mapConfigRepository: new MapConfigTestRepository(), performanceOverviewRepository: new PerformanceOverviewTestRepository(), + roleRepository: new RoleTestRepository(), + incidentManagementTeamRepository: new IncidentManagementTeamTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/TeamMemberD2Repository.ts b/src/data/repositories/TeamMemberD2Repository.ts index 169e5f11..8913f1e4 100644 --- a/src/data/repositories/TeamMemberD2Repository.ts +++ b/src/data/repositories/TeamMemberD2Repository.ts @@ -8,6 +8,7 @@ import { Future } from "../../domain/entities/generic/Future"; const RTSL_ZEBRA_INCIDENTMANAGER = "RTSL_ZEBRA_INCIDENTMANAGER"; const RTSL_ZEBRA_RISKASSESSOR = "RTSL_ZEBRA_RISKASSESSOR"; +const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_MEMBERS = "RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_MEMBERS"; export class TeamMemberD2Repository implements TeamMemberRepository { constructor(private api: D2Api) {} @@ -37,6 +38,10 @@ export class TeamMemberD2Repository implements TeamMemberRepository { return this.getTeamMembersByUserGroup(RTSL_ZEBRA_RISKASSESSOR); } + getForIncidentManagementTeamMembers(): FutureData { + return this.getTeamMembersByUserGroup(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_MEMBERS); + } + private getTeamMembersByUserGroup(userGroupCode: string): FutureData { return apiToFuture( this.api.metadata.get({ @@ -85,11 +90,12 @@ export class TeamMemberD2Repository implements TeamMemberRepository { email: user.email, phone: user.phoneNumber, status: "Available", // TODO: Get status when defined - role: { id: "1", name: "Incident Manager" }, // TODO: Get role when defined photo: photoUrlString && TeamMember.isValidPhotoUrl(photoUrlString) ? new URL(photoUrlString) : undefined, + teamRoles: undefined, + workPosition: undefined, // TODO: Get workPosition when defined }); } } diff --git a/src/data/repositories/consts/DiseaseOutbreakConstants.ts b/src/data/repositories/consts/DiseaseOutbreakConstants.ts index 3bcb78af..6478bffa 100644 --- a/src/data/repositories/consts/DiseaseOutbreakConstants.ts +++ b/src/data/repositories/consts/DiseaseOutbreakConstants.ts @@ -15,6 +15,7 @@ export const RTSL_ZEBRA_RISK_ASSESSMENT_GRADING_PROGRAM_STAGE_ID = "swh2ZukmkDk" export const RTSL_ZEBRA_RISK_ASSESSMENT_SUMMARY_PROGRAM_STAGE_ID = "jBjvgjSgf9d"; export const RTSL_ZEBRA_RISK_ASSESSMENT_QUESTIONNAIRE_PROGRAM_STAGE_ID = "Ltmf2awDAkS"; export const RTSL_ZEBRA_RISK_ASSESSMENT_QUESTIONNAIRE_CUSTOM_PROGRAM_STAGE_ID = "LpB1gNXEbEV"; +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID = "DwEOpUBGgOp"; export const RTSL_ZEBRA_ALERTS_PROGRAM_ID = "MQtbs8UkBxy"; export const RTSL_ZEBRA_ALERTS_NATIONAL_DISEASE_OUTBREAK_EVENT_ID_TEA_ID = "Pq1drzz2HJk"; diff --git a/src/data/repositories/test/TeamMemberTestRepository.ts b/src/data/repositories/test/TeamMemberTestRepository.ts index 91c4d178..38e86470 100644 --- a/src/data/repositories/test/TeamMemberTestRepository.ts +++ b/src/data/repositories/test/TeamMemberTestRepository.ts @@ -12,9 +12,10 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name test`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success([teamMember]); @@ -26,9 +27,26 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name test`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", + }); + + return Future.success([teamMember]); + } + + getForIncidentManagementTeamMembers(): FutureData { + const teamMember: TeamMember = new TeamMember({ + id: "incidentManagementTeamMember", + username: "incidentManagementTeamMember", + name: `Team Member Name test`, + email: `email@email.com`, + phone: `121-1234`, + teamRoles: undefined, + status: "Available", + photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success([teamMember]); @@ -41,9 +59,10 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name test`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success([teamMember]); @@ -56,9 +75,10 @@ export class TeamMemberTestRepository implements TeamMemberRepository { name: `Team Member Name ${id}`, email: `email@email.com`, phone: `121-1234`, - role: { id: "1", name: "role" }, + teamRoles: undefined, status: "Available", photo: new URL("https://www.example.com"), + workPosition: "workPosition", }); return Future.success(teamMember); diff --git a/src/data/repositories/utils/RiskAssessmentMapper.ts b/src/data/repositories/utils/RiskAssessmentMapper.ts index ae433941..8ce97f62 100644 --- a/src/data/repositories/utils/RiskAssessmentMapper.ts +++ b/src/data/repositories/utils/RiskAssessmentMapper.ts @@ -43,6 +43,7 @@ import { RiskAssessmentSummaryFormData, } from "../../../domain/entities/ConfigurableForm"; import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { getPopulatedDataElement, getValueById } from "./helpers"; type D2ProgramStageDataElementsMetadata = { dataElement: SelectedPick< @@ -220,18 +221,6 @@ function mapRiskAssessmentQuestionnaireToDataElements( } } -function getPopulatedDataElement(dataElement: Id, value: Maybe): DataValue { - const populatedDataElement: DataValue = { - dataElement: dataElement, - value: value ?? "", - updatedAt: new Date().toISOString(), - storedBy: "", - createdAt: new Date().toISOString(), - providedElsewhere: false, - }; - return populatedDataElement; -} - function getRiskAssessmentTrackerEvent( programStageId: Id, id: Maybe, @@ -416,7 +405,3 @@ export function mapDataElementsToCustomRiskAssessmentQuestionnaire( return customQuestion; } - -function getValueById(dataValues: DataValue[], dataElement: string): Maybe { - return dataValues.find(dataValue => dataValue.dataElement === dataElement)?.value; -} diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index 8bcaf1f6..beefb3b1 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -1,6 +1,6 @@ import { Maybe } from "../../utils/ts-utils"; import { TeamMember } from "./incident-management-team/TeamMember"; -import { Option } from "./Ref"; +import { Id, Option } from "./Ref"; import { Rule } from "./Rule"; import { DiseaseOutbreakEvent, @@ -10,6 +10,7 @@ import { FormType } from "../../webapp/pages/form-page/FormPage"; import { RiskAssessmentGrading } from "./risk-assessment/RiskAssessmentGrading"; import { RiskAssessmentSummary } from "./risk-assessment/RiskAssessmentSummary"; import { RiskAssessmentQuestionnaire } from "./risk-assessment/RiskAssessmentQuestionnaire"; +import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -84,8 +85,24 @@ export type RiskAssessmentQuestionnaireFormData = BaseFormData & { options: RiskAssessmentQuestionnaireOptions; }; +export type IncidentManagementTeamRoleOptions = { + roles: Option[]; + teamMembers: TeamMember[]; + incidentManagers: TeamMember[]; +}; + +export type IncidentManagementTeamMemberFormData = BaseFormData & { + type: "incident-management-team-member-assignment"; + eventTrackerDetails: DiseaseOutbreakEvent; + entity: Maybe; + incidentManagementTeamRoleId: Maybe; + currentIncidentManagementTeam: Maybe; + options: IncidentManagementTeamRoleOptions; +}; + export type ConfigurableForm = | DiseaseOutbreakEventFormData | RiskAssessmentGradingFormData | RiskAssessmentSummaryFormData - | RiskAssessmentQuestionnaireFormData; + | RiskAssessmentQuestionnaireFormData + | IncidentManagementTeamMemberFormData; diff --git a/src/domain/entities/Rule.ts b/src/domain/entities/Rule.ts index 539f0450..1c9ef85e 100644 --- a/src/domain/entities/Rule.ts +++ b/src/domain/entities/Rule.ts @@ -1,6 +1,9 @@ import { Maybe } from "purify-ts"; -export type Rule = RuleToggleSectionsVisibilityByFieldValue; +export type Rule = + | RuleToggleSectionsVisibilityByFieldValue + | RuleDisableFieldByFieldValue + | RuleDisableFieldOptionWithSameFieldValue; type RuleToggleSectionsVisibilityByFieldValue = { type: "toggleSectionsVisibilityByFieldValue"; @@ -8,3 +11,18 @@ type RuleToggleSectionsVisibilityByFieldValue = { fieldValue: string | boolean | string[] | Date | Maybe | null; sectionIds: string[]; }; + +type RuleDisableFieldByFieldValue = { + type: "disableFieldsByFieldValue"; + fieldId: string; + fieldValue: string | boolean | string[] | Date | Maybe | null; + disableFieldIds: string[]; + sectionIdsWithDisableFields: string[]; +}; + +type RuleDisableFieldOptionWithSameFieldValue = { + type: "disableFieldOptionWithSameFieldValue"; + fieldId: string; + fieldIdsToDisableOption: string[]; + sectionsWithFieldsToDisableOption: string[]; +}; diff --git a/src/domain/entities/incident-management-team/TeamMember.ts b/src/domain/entities/incident-management-team/TeamMember.ts index 54382767..b32c0a62 100644 --- a/src/domain/entities/incident-management-team/TeamMember.ts +++ b/src/domain/entities/incident-management-team/TeamMember.ts @@ -7,7 +7,8 @@ type Email = string; type IncidentManagerStatus = "Available" | "Unavailable"; export type TeamRole = NamedRef & { - level?: number; + roleId: string; + reportsToUsername: Maybe; }; interface TeamMemberAttrs extends NamedRef { @@ -16,7 +17,8 @@ interface TeamMemberAttrs extends NamedRef { email: Maybe; status: Maybe; photo: Maybe; - role: Maybe; + teamRoles: Maybe; + workPosition: Maybe; } export class TeamMember extends Struct() { diff --git a/src/domain/repositories/TeamMemberRepository.ts b/src/domain/repositories/TeamMemberRepository.ts index 1ff60092..26f94769 100644 --- a/src/domain/repositories/TeamMemberRepository.ts +++ b/src/domain/repositories/TeamMemberRepository.ts @@ -7,4 +7,5 @@ export interface TeamMemberRepository { get(id: Id): FutureData; getIncidentManagers(): FutureData; getRiskAssessors(): FutureData; + getForIncidentManagementTeamMembers(): FutureData; } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 44bf45ca..5c420427 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -3,10 +3,12 @@ import { DiseaseOutbreakEvent } from "../entities/disease-outbreak-event/Disease import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; import { OptionsRepository } from "../repositories/OptionsRepository"; import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; +import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; import { getAll } from "./utils/risk-assessment/GetRiskAssessmentById"; export class GetDiseaseOutbreakByIdUseCase { @@ -17,6 +19,7 @@ export class GetDiseaseOutbreakByIdUseCase { teamMemberRepository: TeamMemberRepository; orgUnitRepository: OrgUnitRepository; riskAssessmentRepository: RiskAssessmentRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; } ) {} @@ -43,9 +46,6 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.optionsRepository.getNotificationSource( notificationSourceCode ), - incidentManager: incidentManagerName - ? this.options.teamMemberRepository.get(incidentManagerName) - : Future.success(undefined), areasAffectedProvinces: this.options.orgUnitRepository.get(areasAffectedProvinceIds), areasAffectedDistricts: @@ -56,32 +56,40 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.optionsRepository, this.options.teamMemberRepository ), + incidentManagementTeam: getIncidentManagementTeamById( + id, + this.options.incidentManagementTeamRepository, + this.options.teamMemberRepository + ), }).flatMap( ({ mainSyndrome, suspectedDisease, notificationSource, - incidentManager, areasAffectedProvinces, areasAffectedDistricts, riskAssessment, + incidentManagementTeam, }) => { - const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent( - { - ...diseaseOutbreakEventBase, - createdBy: undefined, //TO DO : FIXME populate once metadata change is done. - mainSyndrome: mainSyndrome, - suspectedDisease: suspectedDisease, - notificationSource: notificationSource, - areasAffectedProvinces: areasAffectedProvinces, - areasAffectedDistricts: areasAffectedDistricts, - incidentManager: incidentManager, - riskAssessment: riskAssessment, - incidentActionPlan: undefined, //TO DO : FIXME populate once incidentActionPlan repo is implemented - incidentManagementTeam: undefined, //TO DO : FIXME populate once incidentManagementTeam repo is implemented - } - ); - return Future.success(diseaseOutbreakEvent); + return this.options.incidentManagementTeamRepository + .getIncidentManagementTeamMember(incidentManagerName, id) + .flatMap(incidentManager => { + const diseaseOutbreakEvent: DiseaseOutbreakEvent = + new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + createdBy: undefined, //TO DO : FIXME populate once metadata change is done. + mainSyndrome: mainSyndrome, + suspectedDisease: suspectedDisease, + notificationSource: notificationSource, + areasAffectedProvinces: areasAffectedProvinces, + areasAffectedDistricts: areasAffectedDistricts, + incidentManager: incidentManager, + riskAssessment: riskAssessment, + incidentActionPlan: undefined, //TO DO : FIXME populate once incidentActionPlan repo is implemented + incidentManagementTeam: incidentManagementTeam, + }); + return Future.success(diseaseOutbreakEvent); + }); } ); }); diff --git a/src/domain/usecases/GetEntityWithOptionsUseCase.ts b/src/domain/usecases/GetEntityWithOptionsUseCase.ts index ba83468c..eec1f885 100644 --- a/src/domain/usecases/GetEntityWithOptionsUseCase.ts +++ b/src/domain/usecases/GetEntityWithOptionsUseCase.ts @@ -6,9 +6,12 @@ import { DiseaseOutbreakEvent } from "../entities/disease-outbreak-event/Disease import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; import { OptionsRepository } from "../repositories/OptionsRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getDiseaseOutbreakWithEventOptions } from "./utils/disease-outbreak/GetDiseaseOutbreakWithOptions"; +import { getIncidentManagementTeamWithOptions } from "./utils/incident-management-team/GetIncidentManagementTeamWithOptions"; import { getRiskAssessmentGradingWithOptions, getRiskAssessmentQuestionnaireWithOptions, @@ -20,7 +23,9 @@ export class GetEntityWithOptionsUseCase { private options: { diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; optionsRepository: OptionsRepository; + roleRepository: RoleRepository; teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; } ) {} @@ -61,6 +66,19 @@ export class GetEntityWithOptionsUseCase { this.options.optionsRepository ); + case "incident-management-team-member-assignment": + if (!eventTrackerDetails) + return Future.error( + new Error( + "Disease outbreak id is required for incident management team member builder" + ) + ); + + return getIncidentManagementTeamWithOptions(id, eventTrackerDetails, { + roleRepository: this.options.roleRepository, + teamMemberRepository: this.options.teamMemberRepository, + incidentManagementTeamRepository: this.options.incidentManagementTeamRepository, + }); default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 20eadae4..377c27ab 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -1,30 +1,101 @@ import { FutureData } from "../../data/api-futures"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { ConfigurableForm } from "../entities/ConfigurableForm"; +import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; +import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbreak"; export class SaveEntityUseCase { constructor( - private diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository, - private riskAssessmentRepository: RiskAssessmentRepository + private options: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + riskAssessmentRepository: RiskAssessmentRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + } ) {} public execute(formData: ConfigurableForm): FutureData { if (!formData || !formData.entity) return Future.error(new Error("No form data found")); switch (formData.type) { case "disease-outbreak-event": - return saveDiseaseOutbreak(this.diseaseOutbreakEventRepository, formData.entity); + return saveDiseaseOutbreak( + { + diseaseOutbreakEventRepository: this.options.diseaseOutbreakEventRepository, + incidentManagementTeamRepository: + this.options.incidentManagementTeamRepository, + teamMemberRepository: this.options.teamMemberRepository, + }, + formData.entity + ); case "risk-assessment-grading": case "risk-assessment-summary": case "risk-assessment-questionnaire": - return this.riskAssessmentRepository.saveRiskAssessment( + return this.options.riskAssessmentRepository.saveRiskAssessment( formData, formData.eventTrackerDetails.id ); + case "incident-management-team-member-assignment": { + const isIncidentManager = formData.entity.teamRoles?.find( + role => + role.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ); + + const hasIncidentManagerChanged = + formData.eventTrackerDetails.incidentManagerName !== formData.entity.username; + + if (isIncidentManager && hasIncidentManagerChanged) { + const updatedIncidentManager = formData.entity.username; + return this.options.diseaseOutbreakEventRepository + .get(formData.eventTrackerDetails.id) + .flatMap(diseaseOutbreakEventBase => { + if ( + diseaseOutbreakEventBase.incidentManagerName !== + updatedIncidentManager + ) { + const updatedDiseaseOutbreakEvent: DiseaseOutbreakEventBaseAttrs = { + ...diseaseOutbreakEventBase, + lastUpdated: new Date(), + incidentManagerName: updatedIncidentManager, + }; + + return saveDiseaseOutbreak( + { + diseaseOutbreakEventRepository: + this.options.diseaseOutbreakEventRepository, + incidentManagementTeamRepository: + this.options.incidentManagementTeamRepository, + teamMemberRepository: this.options.teamMemberRepository, + }, + updatedDiseaseOutbreakEvent + ); + } else { + return Future.success(undefined); + } + }); + } else { + const teamRoleToSave = formData.entity.teamRoles?.find( + role => role.id === formData.incidentManagementTeamRoleId || role.id === "" + ); + + if (!teamRoleToSave) { + return Future.error(new Error("No team role to save found")); + } + + return this.options.incidentManagementTeamRepository.saveIncidentManagementTeamMemberRole( + teamRoleToSave, + formData.entity, + formData.eventTrackerDetails.id + ); + } + } default: return Future.error(new Error("Form type not supported")); } diff --git a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts index 5e2e1a59..a82543d5 100644 --- a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts +++ b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts @@ -1,16 +1,159 @@ import { FutureData } from "../../../../data/api-futures"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { DiseaseOutbreakEventBaseAttrs } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; +import { TeamMember, TeamRole } from "../../../entities/incident-management-team/TeamMember"; import { Id } from "../../../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; +import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; export function saveDiseaseOutbreak( - diseaseOutbreakRepository: DiseaseOutbreakEventRepository, + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs ): FutureData { - return diseaseOutbreakRepository + return repositories.diseaseOutbreakEventRepository .save(diseaseOutbreakEventBaseAttrs) - .flatMap(diseaseOutbreakEventId => { - return Future.success(diseaseOutbreakEventId); + .flatMap(() => { + return saveIncidentManagerTeamMemberRole(repositories, diseaseOutbreakEventBaseAttrs); }); } + +function saveIncidentManagerTeamMemberRole( + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, + diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs +): FutureData { + return repositories.teamMemberRepository.getAll().flatMap(teamMembers => { + return repositories.incidentManagementTeamRepository + .get(diseaseOutbreakEventBaseAttrs.id, teamMembers) + .flatMap(incidentManagementTeam => { + const incidentManagerTeamMemberFound = incidentManagementTeam?.teamHierarchy?.find( + teamMember => + teamMember.teamRoles?.some( + teamRole => + teamRole.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ) + ); + + const incidentManagerTeamRole = incidentManagerTeamMemberFound?.teamRoles?.find( + teamRole => + teamRole.roleId === + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + ); + + if ( + incidentManagerTeamMemberFound && + incidentManagerTeamMemberFound.username !== + diseaseOutbreakEventBaseAttrs.incidentManagerName && + incidentManagerTeamRole + ) { + return changeIncidentManager( + repositories, + diseaseOutbreakEventBaseAttrs, + incidentManagerTeamMemberFound, + incidentManagerTeamRole, + teamMembers + ); + } else { + return createNewIncidentManager( + repositories, + diseaseOutbreakEventBaseAttrs, + teamMembers + ); + } + }); + }); +} + +function changeIncidentManager( + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, + diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, + oldIncidentManager: TeamMember, + oldIncidentManagerTeamRole: TeamRole, + teamMembers: TeamMember[] +): FutureData { + if (oldIncidentManager.username !== diseaseOutbreakEventBaseAttrs.incidentManagerName) { + const newIncidentManager = teamMembers.find( + teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName + ); + if (!newIncidentManager) { + return Future.error( + new Error( + `Incident manager with username ${diseaseOutbreakEventBaseAttrs.incidentManagerName} not found` + ) + ); + } + const newIncidentManagerTeamRole: TeamRole = { + id: "", + name: "", + roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + reportsToUsername: undefined, + }; + return repositories.incidentManagementTeamRepository + .deleteIncidentManagementTeamMemberRole( + oldIncidentManagerTeamRole, + oldIncidentManager, + diseaseOutbreakEventBaseAttrs.id + ) + .flatMap(() => { + return repositories.incidentManagementTeamRepository + .saveIncidentManagementTeamMemberRole( + newIncidentManagerTeamRole, + newIncidentManager, + diseaseOutbreakEventBaseAttrs.id + ) + .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); + }); + } else { + return Future.success(diseaseOutbreakEventBaseAttrs.id); + } +} + +function createNewIncidentManager( + repositories: { + diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + teamMemberRepository: TeamMemberRepository; + }, + diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, + teamMembers: TeamMember[] +): FutureData { + const newIncidentManager = teamMembers.find( + teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName + ); + + if (!newIncidentManager) { + return Future.error( + new Error( + `Incident manager with username ${diseaseOutbreakEventBaseAttrs.incidentManagerName} not found` + ) + ); + } + + const incidentManagerTeamRole: TeamRole = { + id: "", + name: "", + roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + reportsToUsername: undefined, + }; + return repositories.incidentManagementTeamRepository + .saveIncidentManagementTeamMemberRole( + incidentManagerTeamRole, + newIncidentManager, + diseaseOutbreakEventBaseAttrs.id + ) + .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); +} diff --git a/src/types/d2-ui.d.ts b/src/types/d2-ui.d.ts index 539ca0bc..d11a6526 100644 --- a/src/types/d2-ui.d.ts +++ b/src/types/d2-ui.d.ts @@ -9,8 +9,10 @@ declare module "@dhis2/ui" { export function IconArrowRight24(props: { color?: string }): React.ReactElement; export function IconCalendar24(props: { color?: string }): React.ReactElement; export function IconChevronDown24(props: { color?: string }): React.ReactElement; + export function IconChevronRight24(props: { color?: string }): React.ReactElement; export function IconCross24(props: { color?: string }): React.ReactElement; export function IconCross16(props: { color?: string }): React.ReactElement; export function IconSearch24(props: { color?: string }): React.ReactElement; export function IconInfo24(props: { color?: string }): React.ReactElement; + export function IconEditItems24(props: { color?: string }): React.ReactElement; } diff --git a/src/webapp/components/form/Form.tsx b/src/webapp/components/form/Form.tsx index fc8d710d..52632974 100644 --- a/src/webapp/components/form/Form.tsx +++ b/src/webapp/components/form/Form.tsx @@ -18,7 +18,6 @@ export type FormProps = { export const Form: React.FC = React.memo(props => { const { formState, onFormChange, onSave, onCancel, errorLabels, handleAddNew } = props; - const { formLocalState, handleUpdateFormField } = useLocalForm(formState, onFormChange); return ( diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 695a046c..2ce46fd8 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -4,6 +4,7 @@ import { User } from "../user-selector/UserSelector"; 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"; @@ -205,3 +206,30 @@ export function validateField( export function hideFieldsAndSetToEmpty(fields: FormFieldState[]): FormFieldState[] { return fields.map(field => ({ ...getFieldWithEmptyValue(field), isVisible: false })); } + +export function applyRulesInUpdatedField( + updatedField: FormFieldState, + formRules: Rule[] +): FormFieldState { + const filteredRulesByFieldId = formRules.filter(rule => rule.fieldId === updatedField.id); + + if (filteredRulesByFieldId.length === 0) { + return updatedField; + } + + const formStateWithRulesApplied = filteredRulesByFieldId.reduce((currentUpdatedField, rule) => { + switch (rule.type) { + case "disableFieldsByFieldValue": + return rule.disableFieldIds.includes(currentUpdatedField.id) + ? { + ...currentUpdatedField, + disabled: currentUpdatedField.value === rule.fieldValue, + } + : currentUpdatedField; + default: + return currentUpdatedField; + } + }, updatedField); + + return formStateWithRulesApplied; +} diff --git a/src/webapp/components/form/FormSectionsState.ts b/src/webapp/components/form/FormSectionsState.ts index 732378ba..e006b722 100644 --- a/src/webapp/components/form/FormSectionsState.ts +++ b/src/webapp/components/form/FormSectionsState.ts @@ -166,6 +166,8 @@ export function toggleSectionVisibilityByFieldValue( fieldValue: FormFieldState["value"], rule: Rule ): FormSectionState { + if (rule.type !== "toggleSectionsVisibilityByFieldValue") return section; + if (rule.sectionIds.includes(section.id)) { const subsections = section.subsections?.map(subsection => { return toggleSectionVisibilityByFieldValue(subsection, fieldValue, rule); @@ -201,3 +203,89 @@ export function toggleSectionVisibilityByFieldValue( }; } } + +export function disableFieldsByFieldValueInSection( + section: FormSectionState, + fieldValue: FormFieldState["value"], + rule: Rule +): FormSectionState { + if (rule.type !== "disableFieldsByFieldValue") return section; + + if (rule.sectionIdsWithDisableFields.includes(section.id)) { + const subsections = section.subsections?.map(subsection => { + return disableFieldsByFieldValueInSection(subsection, fieldValue, rule); + }); + + const fieldsInSection: FormFieldState[] = section.fields.map(field => { + return rule.disableFieldIds.includes(field.id) + ? { + ...field, + disabled: fieldValue === rule.fieldValue, + } + : field; + }); + + return section.subsections + ? { + ...section, + fields: fieldsInSection, + subsections: subsections, + } + : { + ...section, + fields: fieldsInSection, + }; + } else { + return { + ...section, + subsections: section.subsections?.map(subsection => + disableFieldsByFieldValueInSection(subsection, fieldValue, rule) + ), + }; + } +} + +export function disableFieldOptionWithSameFieldValueInSection( + section: FormSectionState, + fieldValue: FormFieldState["value"], + rule: Rule +): FormSectionState { + if (rule.type !== "disableFieldOptionWithSameFieldValue") return section; + + if (rule.sectionsWithFieldsToDisableOption.includes(section.id)) { + const subsections = section.subsections?.map(subsection => { + return disableFieldOptionWithSameFieldValueInSection(subsection, fieldValue, rule); + }); + + const fieldsInSection: FormFieldState[] = section.fields.map(field => { + return rule.fieldIdsToDisableOption.includes(field.id) && + (field.type === "select" || field.type === "user" || field.type === "radio") + ? ({ + ...field, + options: field.options?.map(option => ({ + ...option, + disabled: option.value === fieldValue, + })), + } as FormFieldState) + : field; + }); + + return section.subsections + ? { + ...section, + fields: fieldsInSection, + subsections: subsections, + } + : { + ...section, + fields: fieldsInSection, + }; + } else { + return { + ...section, + subsections: section.subsections?.map(subsection => + disableFieldOptionWithSameFieldValueInSection(subsection, fieldValue, rule) + ), + }; + } +} diff --git a/src/webapp/components/form/__tests__/Form.spec.tsx b/src/webapp/components/form/__tests__/Form.spec.tsx index 5b923e9a..95ae764a 100644 --- a/src/webapp/components/form/__tests__/Form.spec.tsx +++ b/src/webapp/components/form/__tests__/Form.spec.tsx @@ -449,7 +449,7 @@ function givenFormProps(): FormProps { { value: "1", label: "user 1", - workPosition: "Postion", + workPosition: "workPosition", phone: "PhoneNumber", email: "Email", status: "Available", @@ -458,7 +458,7 @@ function givenFormProps(): FormProps { { value: "2", label: "user 2", - workPosition: "Postion", + workPosition: "workPosition", phone: "PhoneNumber", email: "Email", status: "Unavailable", diff --git a/src/webapp/components/layout/side-bar/SideBarContent.tsx b/src/webapp/components/layout/side-bar/SideBarContent.tsx index 21717a9d..6d1e7fc1 100644 --- a/src/webapp/components/layout/side-bar/SideBarContent.tsx +++ b/src/webapp/components/layout/side-bar/SideBarContent.tsx @@ -7,6 +7,7 @@ import { AddCircleOutline } from "@material-ui/icons"; import i18n from "../../../../utils/i18n"; import { Button } from "../../button/Button"; import { RouteName, routes, useRoutes } from "../../../hooks/useRoutes"; +import { useCurrentEventTracker } from "../../../contexts/current-event-tracker-context"; type SideBarContentProps = { children?: React.ReactNode; @@ -45,6 +46,7 @@ const DEFAULT_SIDEBAR_OPTIONS: SideBarOption[] = [ export const SideBarContent: React.FC = React.memo( ({ children, hideOptions = false, showCreateEvent = false }) => { const { goTo } = useRoutes(); + const { getCurrentEventTracker } = useCurrentEventTracker(); const goToCreateEvent = useCallback(() => { goTo(RouteName.CREATE_FORM, { formType: "disease-outbreak-event" }); @@ -63,7 +65,20 @@ export const SideBarContent: React.FC = React.memo( ) : ( {DEFAULT_SIDEBAR_OPTIONS.map(({ text, value }) => ( - + ))} diff --git a/src/webapp/components/user-selector/UserCard.tsx b/src/webapp/components/user-selector/UserCard.tsx index 3b70d134..a8cc7011 100644 --- a/src/webapp/components/user-selector/UserCard.tsx +++ b/src/webapp/components/user-selector/UserCard.tsx @@ -10,6 +10,7 @@ type UserCardProps = { }; export const UserCard: React.FC = React.memo(props => { const { selectedUser } = props; + return ( @@ -18,21 +19,23 @@ export const UserCard: React.FC = React.memo(props => { {selectedUser?.label} {selectedUser?.workPosition && {selectedUser?.workPosition}} - - - - {selectedUser?.phone} - - {selectedUser?.email} - + {selectedUser?.teamRoles && {selectedUser?.teamRoles}} + {selectedUser?.phone && {selectedUser?.phone}} + {selectedUser?.email && ( + + {selectedUser?.email} + + )} -
- {i18n.t("Status: ", { nsSeparator: false })} + {selectedUser?.status && ( +
+ {i18n.t("Status: ", { nsSeparator: false })} - {selectedUser?.status && {selectedUser?.status}} -
+ {selectedUser?.status && {selectedUser?.status}} +
+ )}
diff --git a/src/webapp/components/user-selector/UserSelector.tsx b/src/webapp/components/user-selector/UserSelector.tsx index d2c3db9c..0bb3b118 100644 --- a/src/webapp/components/user-selector/UserSelector.tsx +++ b/src/webapp/components/user-selector/UserSelector.tsx @@ -13,6 +13,7 @@ export type User = { status?: string; src?: string; alt?: string; + teamRoles?: string; }; type UserSelectorProps = { diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index f7285bd8..c21da7df 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -19,6 +19,7 @@ const formTypes = [ "risk-assessment-grading", "risk-assessment-summary", "risk-assessment-questionnaire", + "incident-management-team-member-assignment", ] as const satisfies FormType[]; const formType = `:formType(${join(formTypes, "|")})` as const; @@ -27,7 +28,7 @@ export const routes: Record = { [RouteName.CREATE_FORM]: `/create/${formType}`, [RouteName.EDIT_FORM]: `/edit/${formType}/:id`, [RouteName.EVENT_TRACKER]: "/event-tracker/:id", - [RouteName.IM_TEAM_BUILDER]: "/incident-management-team-builder", + [RouteName.IM_TEAM_BUILDER]: "/incident-management-team-builder/:id", [RouteName.INCIDENT_ACTION_PLAN]: "/incident-action-plan", [RouteName.RESOURCES]: "/resources", [RouteName.DASHBOARD]: "/", @@ -37,7 +38,7 @@ type RouteParams = { [RouteName.CREATE_FORM]: { formType: FormType }; [RouteName.EDIT_FORM]: { formType: FormType; id: string }; [RouteName.EVENT_TRACKER]: { id: string }; - [RouteName.IM_TEAM_BUILDER]: undefined; + [RouteName.IM_TEAM_BUILDER]: { id: string }; [RouteName.INCIDENT_ACTION_PLAN]: undefined; [RouteName.RESOURCES]: undefined; [RouteName.DASHBOARD]: undefined; diff --git a/src/webapp/pages/form-page/FormPage.tsx b/src/webapp/pages/form-page/FormPage.tsx index d336fe65..881cc25a 100644 --- a/src/webapp/pages/form-page/FormPage.tsx +++ b/src/webapp/pages/form-page/FormPage.tsx @@ -11,7 +11,8 @@ export type FormType = | "disease-outbreak-event" | "risk-assessment-grading" | "risk-assessment-questionnaire" - | "risk-assessment-summary"; + | "risk-assessment-summary" + | "incident-management-team-member-assignment"; export const FormPage: React.FC = React.memo(() => { const { formType, id } = useParams<{ 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 93fb3c1f..95392ca7 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts @@ -42,10 +42,12 @@ export const diseaseOutbreakEventFieldIds = { } as const; export function mapTeamMemberToUser(teamMember: TeamMember): User { + const teamRoles = teamMember.teamRoles?.map(role => role.name).join(", "); return { value: teamMember.username, label: teamMember.name, - workPosition: teamMember.role?.name || "", + workPosition: teamMember.workPosition || "", + teamRoles: teamRoles || "", phone: teamMember.phone || "", email: teamMember.email || "", status: teamMember.status || "", diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts index c817105f..522a297c 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/utils/applyRulesInFormState.ts @@ -1,6 +1,10 @@ import { Rule } from "../../../../../domain/entities/Rule"; import { FormFieldState } from "../../../../components/form/FormFieldsState"; -import { toggleSectionVisibilityByFieldValue } from "../../../../components/form/FormSectionsState"; +import { + disableFieldOptionWithSameFieldValueInSection, + disableFieldsByFieldValueInSection, + toggleSectionVisibilityByFieldValue, +} from "../../../../components/form/FormSectionsState"; import { FormState } from "../../../../components/form/FormState"; export function applyRulesInFormState( @@ -23,6 +27,24 @@ export function applyRulesInFormState( toggleSectionVisibilityByFieldValue(section, updatedField.value, rule) ), }; + case "disableFieldsByFieldValue": + return { + ...formState, + sections: formState.sections.map(section => + disableFieldsByFieldValueInSection(section, updatedField.value, rule) + ), + }; + case "disableFieldOptionWithSameFieldValue": + return { + ...formState, + sections: formState.sections.map(section => + disableFieldOptionWithSameFieldValueInSection( + section, + updatedField.value, + rule + ) + ), + }; } }, currentFormState); diff --git a/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts index 5cbac64c..67783f96 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/utils/updateDiseaseOutbreakEventFormState.ts @@ -63,6 +63,8 @@ function validateFormState( break; case "risk-assessment-questionnaire": break; + case "incident-management-team-member-assignment": + break; } return [...formValidationErrors, ...entityValidationErrors]; diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index 125e68b5..76293370 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -5,6 +5,7 @@ import { FormState } from "../../components/form/FormState"; import { User } from "../../components/user-selector/UserSelector"; import { Option as PresentationOption } from "../../components/utils/option"; import { mapDiseaseOutbreakEventToInitialFormState } from "./disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState"; +import { mapIncidentManagementTeamMemberToInitialFormState } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { mapRiskAssessmentQuestionnaireToInitialFormState, mapRiskAssessmentSummaryToInitialFormState, @@ -24,6 +25,8 @@ export function mapEntityToFormState( return mapRiskAssessmentSummaryToInitialFormState(configurableForm); case "risk-assessment-questionnaire": return mapRiskAssessmentQuestionnaireToInitialFormState(configurableForm); + case "incident-management-team-member-assignment": + return mapIncidentManagementTeamMemberToInitialFormState(configurableForm); } } @@ -37,10 +40,12 @@ export function mapToPresentationOptions(options: Option[]): PresentationOption[ } export function mapTeamMemberToUser(teamMember: TeamMember): User { + const teamRoles = teamMember.teamRoles?.map(role => role.name).join(", "); return { value: teamMember.username, label: teamMember.name, - workPosition: teamMember.role?.name || "", + workPosition: teamMember.workPosition || "", + teamRoles: teamRoles || "", phone: teamMember.phone || "", email: teamMember.email || "", status: teamMember.status || "", diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 546fecbe..46742ebe 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -17,6 +17,7 @@ import { import { ConfigurableForm, DiseaseOutbreakEventFormData, + IncidentManagementTeamMemberFormData, RiskAssessmentGradingFormData, RiskAssessmentQuestionnaireFormData, RiskAssessmentQuestionnaireOptions, @@ -34,6 +35,9 @@ import { RiskAssessmentQuestion, RiskAssessmentQuestionnaire, } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; +import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; +import { incidentManagementTeamBuilderCodes } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; export function mapFormStateToEntityData( formState: FormState, @@ -82,6 +86,16 @@ export function mapFormStateToEntityData( return riskQuestionnaireForm; } + case "incident-management-team-member-assignment": { + const incidentManagementTeamMember: TeamMember = + mapFormStateToIncidentManagementTeamMember(formState, formData); + const incidentManagementTeamMemberForm: IncidentManagementTeamMemberFormData = { + ...formData, + entity: incidentManagementTeamMember, + }; + return incidentManagementTeamMemberForm; + } + default: return formData; } @@ -466,3 +480,53 @@ function getRiskAssessmentQuestionsWithOption( return { likelihoodOption, consequencesOption, riskOption }; } + +function mapFormStateToIncidentManagementTeamMember( + formState: FormState, + formData: IncidentManagementTeamMemberFormData +): TeamMember { + const { options, incidentManagementTeamRoleId } = formData; + const { roles, teamMembers } = options; + + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + const getStringFieldValueById = (id: string): string => getStringFieldValue(id, allFields); + + const teamRoleSelected = roles.find( + role => role.id === getStringFieldValueById(TEAM_ROLE_FIELD_ID) + ); + const teamMemberAssigned = teamMembers.find(teamMember => { + return ( + teamMember.username === + getStringFieldValueById(incidentManagementTeamBuilderCodes.teamMemberAssigned) + ); + }); + + const reportsToUserNameSelected = + getStringFieldValueById(incidentManagementTeamBuilderCodes.reportsToUsername) || ""; + + const filteredTeamMemberAssignedRoles = teamMemberAssigned?.teamRoles?.filter( + teamRole => teamRole.id !== incidentManagementTeamRoleId + ); + + const newTeamMemberAssignedRoles = [ + ...(filteredTeamMemberAssignedRoles || []), + { + id: incidentManagementTeamRoleId || "", + roleId: teamRoleSelected?.id || "", + name: teamRoleSelected?.name || "", + reportsToUsername: reportsToUserNameSelected, + }, + ]; + + return new TeamMember({ + id: teamMemberAssigned?.id || "", + teamRoles: teamRoleSelected ? newTeamMemberAssignedRoles : undefined, + username: teamMemberAssigned?.username || "", + name: teamMemberAssigned?.name || "", + phone: teamMemberAssigned?.phone, + email: teamMemberAssigned?.email, + photo: teamMemberAssigned?.photo, + workPosition: teamMemberAssigned?.workPosition, + status: teamMemberAssigned?.status, + }); +} diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index cf4e1bcb..6c9c0d8d 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -16,6 +16,7 @@ import { addNewCustomQuestionSection, getAnotherOptionSection, } from "./risk-assessment/mapRiskAssessmentToInitialFormState"; +import { DiseaseOutbreakEvent } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; export type GlobalMessage = { text: string; @@ -57,11 +58,15 @@ export function useForm(formType: FormType, id?: Id): State { const [globalMessage, setGlobalMessage] = useState>(); const [formState, setFormState] = useState({ kind: "loading" }); const [configurableForm, setConfigurableForm] = useState(); + const [currentEventTrackerState, setCurrentEventTrackerState] = + useState>(); const [formLabels, setFormLabels] = useState(); const [isLoading, setIsLoading] = useState(false); const currentEventTracker = getCurrentEventTracker(); useEffect(() => { + if (currentEventTrackerState?.id === currentEventTracker?.id) return; + compositionRoot.getWithOptions.execute(formType, currentEventTracker, id).run( formData => { setConfigurableForm(formData); @@ -70,6 +75,7 @@ export function useForm(formType: FormType, id?: Id): State { kind: "loaded", data: mapEntityToFormState(formData, !!id), }); + setCurrentEventTrackerState(currentEventTracker); }, error => { setFormState({ @@ -82,9 +88,16 @@ export function useForm(formType: FormType, id?: Id): State { ), type: "error", }); + setCurrentEventTrackerState(currentEventTracker); } ); - }, [compositionRoot.getWithOptions, currentEventTracker, formType, id]); + }, [ + compositionRoot.getWithOptions, + formType, + id, + currentEventTracker, + currentEventTrackerState?.id, + ]); const handleAddNew = useCallback(() => { if (formState.kind !== "loaded" || !configurableForm) return; @@ -222,6 +235,17 @@ export function useForm(formType: FormType, id?: Id): State { 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 => { @@ -241,12 +265,22 @@ export function useForm(formType: FormType, id?: Id): State { ]); const onCancelForm = useCallback(() => { - if (currentEventTracker) - goTo(RouteName.EVENT_TRACKER, { - id: currentEventTracker.id, - }); - else goTo(RouteName.DASHBOARD); - }, [currentEventTracker, goTo]); + if (currentEventTracker) { + switch (formType) { + case "incident-management-team-member-assignment": + goTo(RouteName.IM_TEAM_BUILDER, { + id: currentEventTracker.id, + }); + break; + default: + goTo(RouteName.EVENT_TRACKER, { + id: currentEventTracker.id, + }); + } + } else { + goTo(RouteName.DASHBOARD); + } + }, [currentEventTracker, goTo, formType]); return { formLabels, diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 37b10744..9f70c01e 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -1,13 +1,143 @@ -import React from "react"; +import React, { useEffect } from "react"; +import styled from "styled-components"; +import { IconUser24, IconEditItems24 } from "@dhis2/ui"; +import { useParams } from "react-router-dom"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { DeleteOutline } from "@material-ui/icons"; import { Layout } from "../../components/layout/Layout"; import i18n from "../../../utils/i18n"; +import LoaderContainer from "../../components/loader/LoaderContainer"; +import { UserCard } from "../../components/user-selector/UserCard"; +import { Section } from "../../components/section/Section"; +import { Button } from "../../components/button/Button"; +import { IMTeamHierarchyView } from "../../components/im-team-hierarchy/IMTeamHierarchyView"; +import { useIMTeamBuilder } from "./useIMTeamBuilder"; +import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; +import { SimpleModal } from "../../components/simple-modal/SimpleModal"; export const IMTeamBuilderPage: React.FC = React.memo(() => { + const { id } = useParams<{ + id: string; + }>(); + const snackbar = useSnackbar(); + const { getCurrentEventTracker } = useCurrentEventTracker(); + const { + globalMessage, + incidentManagerUser, + lastUpdated, + incidentManagementTeamHierarchyItems, + selectedHierarchyItemId, + openDeleteModalData, + disableDeletion, + onSelectHierarchyItem, + goToIncidentManagementTeamRole, + onDeleteIncidentManagementTeamMember, + onOpenDeleteModalData, + } = useIMTeamBuilder(id); + + useEffect(() => { + if (!globalMessage) return; + + snackbar[globalMessage.type](globalMessage.text); + }, [globalMessage, snackbar]); + return ( + subtitle={getCurrentEventTracker()?.name || ""} + > + + + {incidentManagerUser && } + + +
+ + + {selectedHierarchyItemId && ( + + )} + + } + > + + + onOpenDeleteModalData(undefined)} + title={i18n.t("Delete team role")} + closeLabel={i18n.t("Cancel")} + footerButtons={ + + } + > + {openDeleteModalData && ( + + {openDeleteModalData.teamRole}: + {openDeleteModalData.member?.name} + + )} + +
+
+ ); }); + +const UserCardContainer = styled.div` + width: fit-content; + margin-block-end: 48px; +`; + +const ButtonsContainer = styled.div` + display: flex; + gap: 8px; +`; + +const RoleAndMemberWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const RoleWrapper = styled.div` + font-weight: 700; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; + +const MemberWrapper = styled.div` + font-weight: 400; + font-size: 14px; + color: ${props => props.theme.palette.common.grey900}; +`; From 214ec57e7fccb8d0e329efe269dbf5ea5ac4367a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 11:49:54 +0200 Subject: [PATCH 14/24] Add translations --- i18n/en.pot | 21 ++++++++++++++++++--- i18n/es.po | 23 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 495e3a38..6944d7db 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-30T07:39:32.843Z\n" -"PO-Revision-Date: 2024-09-30T07:39:32.843Z\n" +"POT-Creation-Date: 2024-10-09T09:46:34.412Z\n" +"PO-Revision-Date: 2024-10-09T09:46:34.412Z\n" msgid "Low" msgstr "" @@ -90,7 +90,7 @@ msgstr "" msgid "Notes" msgstr "" -msgid "Notes" +msgid "Currently assigned:" msgstr "" msgid "Create Event" @@ -174,6 +174,9 @@ msgstr "" msgid "Risk Assessment Questionnaire saved successfully" msgstr "" +msgid "Incident Management Team Member saved successfully" +msgstr "" + msgid "Incident Action Plan" msgstr "" @@ -183,5 +186,17 @@ msgstr "" msgid "Incident Management Team Builder" msgstr "" +msgid "Edit Role" +msgstr "" + +msgid "Assign Role" +msgstr "" + +msgid "Delete Role" +msgstr "" + +msgid "Delete team role" +msgstr "" + msgid "Resources" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index e9452c28..74942176 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-09-12T14:10:04.460Z\n" +"POT-Creation-Date: 2024-10-09T09:46:34.412Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -86,6 +86,12 @@ msgstr "" msgid "Edit Details" msgstr "" +msgid "Notes" +msgstr "" + +msgid "Currently assigned:" +msgstr "" + msgid "Create Event" msgstr "" @@ -167,6 +173,9 @@ msgstr "" msgid "Risk Assessment Questionnaire saved successfully" msgstr "" +msgid "Incident Management Team Member saved successfully" +msgstr "" + msgid "Incident Action Plan" msgstr "" @@ -176,6 +185,18 @@ msgstr "" msgid "Incident Management Team Builder" msgstr "" +msgid "Edit Role" +msgstr "" + +msgid "Assign Role" +msgstr "" + +msgid "Delete Role" +msgstr "" + +msgid "Delete team role" +msgstr "" + msgid "Resources" msgstr "" From a183db7287a016dbdbe71e88ab3f3376766b44bb Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:01:08 +0200 Subject: [PATCH 15/24] Improve UI --- .../components/im-team-hierarchy/IMTeamHierarchyItem.tsx | 7 ++++--- .../incident-management-team-builder/IMTeamBuilderPage.tsx | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx index ca04b388..7913f315 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx @@ -127,12 +127,13 @@ const RoleAndMemberWrapper = styled.div` const RoleWrapper = styled.div` font-weight: 700; - font-size: 14px; + font-size: 0.875rem; color: ${props => props.theme.palette.common.grey900}; `; const MemberWrapper = styled.div` font-weight: 400; - font-size: 14px; - color: ${props => props.theme.palette.common.grey900}; + font-size: 0.875rem; + text-decoration: underline; + color: ${props => props.theme.palette.common.blue800}; `; diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 9f70c01e..0f515cb9 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -132,12 +132,12 @@ const RoleAndMemberWrapper = styled.div` const RoleWrapper = styled.div` font-weight: 700; - font-size: 14px; + font-size: 0.875rem; color: ${props => props.theme.palette.common.grey900}; `; const MemberWrapper = styled.div` font-weight: 400; - font-size: 14px; + font-size: 0.875rem; color: ${props => props.theme.palette.common.grey900}; `; From bf0e4027d19f2138d2422a83096a2e151899e375 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:28:00 +0200 Subject: [PATCH 16/24] Add edit profile button --- i18n/en.pot | 7 +++++-- i18n/es.po | 5 ++++- .../im-team-hierarchy/TeamMemberProfile.tsx | 20 ++++++++++++++++++- .../components/profile-modal/ProfileModal.tsx | 6 ++++-- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 6944d7db..ae5f2314 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-10-09T09:46:34.412Z\n" -"PO-Revision-Date: 2024-10-09T09:46:34.412Z\n" +"POT-Creation-Date: 2024-10-09T10:17:08.902Z\n" +"PO-Revision-Date: 2024-10-09T10:17:08.902Z\n" msgid "Low" msgstr "" @@ -90,6 +90,9 @@ msgstr "" msgid "Notes" msgstr "" +msgid "Edit Profile" +msgstr "" + msgid "Currently assigned:" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 74942176..dea17a7c 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-10-09T09:46:34.412Z\n" +"POT-Creation-Date: 2024-10-09T10:17:08.902Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -89,6 +89,9 @@ msgstr "" msgid "Notes" msgstr "" +msgid "Edit Profile" +msgstr "" + msgid "Currently assigned:" msgstr "" diff --git a/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx index 172e9936..0e378c96 100644 --- a/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx +++ b/src/webapp/components/im-team-hierarchy/TeamMemberProfile.tsx @@ -1,10 +1,13 @@ -import React, { useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import styled from "styled-components"; import { Link } from "@material-ui/core"; +import { IconEditItems24 } from "@dhis2/ui"; import i18n from "../../../utils/i18n"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { ProfileModal } from "../profile-modal/ProfileModal"; +import { useAppContext } from "../../contexts/app-context"; +import { Button } from "../button/Button"; type TeamMemberProfileProps = { open: boolean; @@ -15,12 +18,17 @@ type TeamMemberProfileProps = { export const TeamMemberProfile: React.FC = React.memo(props => { const { open, setOpen, member, diseaseOutbreakEventName } = props; + const { api } = useAppContext(); const teamRolesNames = useMemo( () => member.teamRoles?.map(role => role.name).join(", "), [member.teamRoles] ); + const onRedirectToProfile = useCallback(() => { + window.open(`${api.baseUrl}/dhis-web-user/index.html#/users/edit/${member.id}`, "_blank"); + }, [api.baseUrl, member.id]); + return ( = React.memo(pr name={member.name} src={member.photo?.toString()} alt={member.photo ? `Photo of ${member.name}` : undefined} + footerButtons={ + + } > {member.phone} diff --git a/src/webapp/components/profile-modal/ProfileModal.tsx b/src/webapp/components/profile-modal/ProfileModal.tsx index 9e58e38d..bf952cd9 100644 --- a/src/webapp/components/profile-modal/ProfileModal.tsx +++ b/src/webapp/components/profile-modal/ProfileModal.tsx @@ -8,7 +8,7 @@ import { Button } from "../button/Button"; type ProfileModalProps = { name: string; children: React.ReactNode; - avatarSize?: "small" | "medium"; + footerButtons?: React.ReactNode; alt?: string; src?: string; open: boolean; @@ -16,7 +16,7 @@ type ProfileModalProps = { }; export const ProfileModal: React.FC = React.memo( - ({ children, src, alt, open = false, onClose, name }) => { + ({ children, footerButtons, src, alt, open = false, onClose, name }) => { return ( = React.memo(
+ {footerButtons ?? null}
@@ -61,6 +62,7 @@ const Name = styled.span` const Footer = styled.div` display: flex; margin-block-start: 16px; + gap: 16px; `; const StyledCard = styled(Card)` From ddd8e233fd02553320025b41f262170dd678cfc6 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:52:01 +0200 Subject: [PATCH 17/24] Fix delete members inside tree --- .../IMTeamBuilderPage.tsx | 6 +- .../useIMTeamBuilder.ts | 64 +++++++++++++------ 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 0f515cb9..5f9f4082 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -103,8 +103,10 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { > {openDeleteModalData && ( - {openDeleteModalData.teamRole}: - {openDeleteModalData.member?.name} + {openDeleteModalData.teamRole.name}: + + {openDeleteModalData.teamMember.name}{" "} + )} diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts index 576a3ceb..cb159af9 100644 --- a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -17,6 +17,11 @@ type GlobalMessage = { type: "warning" | "success" | "error"; }; +export type ProfileModalData = { + teamMember: TeamMember; + teamRole: TeamRole; +}; + type State = { globalMessage: Maybe; incidentManagementTeamHierarchyItems: Maybe; @@ -26,7 +31,7 @@ type State = { onDeleteIncidentManagementTeamMember: () => void; incidentManagerUser: Maybe; lastUpdated: string; - openDeleteModalData: IMTeamHierarchyOption | undefined; + openDeleteModalData: ProfileModalData | undefined; onOpenDeleteModalData: (selectedHierarchyItemId: Id | undefined) => void; disableDeletion: boolean; }; @@ -43,11 +48,11 @@ export function useIMTeamBuilder(id: Id): State { >(); const [selectedHierarchyItemId, setSelectedHierarchyItemId] = useState(""); const [disableDeletion, setDisableDeletion] = useState(false); - const [openDeleteModalData, setOpenDeleteModalData] = useState< - IMTeamHierarchyOption | undefined - >(undefined); + const [openDeleteModalData, setOpenDeleteModalData] = useState( + undefined + ); - useEffect(() => { + const getIncidentManagementTeam = useCallback(() => { compositionRoot.incidentManagementTeam.get.execute(id).run( incidentManagementTeam => { setIncidentManagementTeam(incidentManagementTeam); @@ -67,6 +72,10 @@ export function useIMTeamBuilder(id: Id): State { ); }, [compositionRoot.incidentManagementTeam.get, id]); + useEffect(() => { + getIncidentManagementTeam(); + }, [getIncidentManagementTeam]); + const goToIncidentManagementTeamRole = useCallback(() => { if (selectedHierarchyItemId) { goTo(RouteName.EDIT_FORM, { @@ -84,17 +93,23 @@ export function useIMTeamBuilder(id: Id): State { (nodeId: string, selected: boolean) => { const selection = selected ? nodeId : ""; const incidentManagementTeamItemSelected = selection - ? incidentManagementTeamHierarchyItems?.find(item => item.id === selection) + ? incidentManagementTeam?.teamHierarchy.find(teamMember => + teamMember.teamRoles?.some(role => role.id === selection) + ) : undefined; + const selectedRole = incidentManagementTeamItemSelected?.teamRoles?.find( + role => role.id === selection + ); + const isIncidentManagerRoleSelected = - incidentManagementTeamItemSelected?.teamRoleId === + selectedRole?.roleId === RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole; setSelectedHierarchyItemId(selection); setDisableDeletion(isIncidentManagerRoleSelected); }, - [incidentManagementTeamHierarchyItems] + [incidentManagementTeam?.teamHierarchy] ); const onOpenDeleteModalData = useCallback( @@ -102,27 +117,35 @@ export function useIMTeamBuilder(id: Id): State { if (!selectedHierarchyItemId) { setOpenDeleteModalData(undefined); } else { - const incidentManagementTeamItem = incidentManagementTeamHierarchyItems?.find( - item => item.id === selectedHierarchyItemId + const incidentManagementTeamItem = incidentManagementTeam?.teamHierarchy.find( + teamMember => + teamMember.teamRoles?.some(role => role.id === selectedHierarchyItemId) + ); + + const selectedRole = incidentManagementTeamItem?.teamRoles?.find( + role => role.id === selectedHierarchyItemId ); - if (incidentManagementTeamItem) { - setOpenDeleteModalData(incidentManagementTeamItem); + if (incidentManagementTeamItem && selectedRole) { + setOpenDeleteModalData({ + teamRole: selectedRole, + teamMember: incidentManagementTeamItem, + }); } } }, - [incidentManagementTeamHierarchyItems] + [incidentManagementTeam?.teamHierarchy] ); const onDeleteIncidentManagementTeamMember = useCallback(() => { if (disableDeletion) return; - const teamMember = incidentManagementTeamHierarchyItems?.find( - item => item.id === selectedHierarchyItemId - )?.member; + const teamMember = incidentManagementTeam?.teamHierarchy.find(teamMember => + teamMember.teamRoles?.some(role => role.id === selectedHierarchyItemId) + ); const teamRoleToDelete = teamMember?.teamRoles?.find( - teamRole => teamRole.id === selectedHierarchyItemId + role => role.id === selectedHierarchyItemId ); if (teamMember && teamRoleToDelete) { @@ -134,6 +157,8 @@ export function useIMTeamBuilder(id: Id): State { text: `${teamMember.name} deleted from Incident Management Team`, type: "success", }); + getIncidentManagementTeam(); + onOpenDeleteModalData(undefined); }, err => { console.debug(err); @@ -141,6 +166,7 @@ export function useIMTeamBuilder(id: Id): State { text: `Error deleting ${teamMember.name} from Incident Management Team`, type: "error", }); + onOpenDeleteModalData(undefined); } ); } else { @@ -152,8 +178,10 @@ export function useIMTeamBuilder(id: Id): State { }, [ compositionRoot.incidentManagementTeam.deleteIncidentManagementTeamMemberRole, disableDeletion, + getIncidentManagementTeam, id, - incidentManagementTeamHierarchyItems, + incidentManagementTeam?.teamHierarchy, + onOpenDeleteModalData, selectedHierarchyItemId, ]); From b82d19f93e753eed6510a0c9760041727cfed58d Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:56:12 +0200 Subject: [PATCH 18/24] Improve messages to user --- src/webapp/pages/form-page/useForm.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 6c9c0d8d..0aeca5ee 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -80,12 +80,10 @@ export function useForm(formType: FormType, id?: Id): State { error => { setFormState({ kind: "error", - message: i18n.t(`Create Event form cannot be loaded`), + message: i18n.t(`Form cannot be loaded`), }); setGlobalMessage({ - text: i18n.t( - `An error occurred while loading Create Event form: ${error.message}` - ), + text: i18n.t(`An error occurred while loading form: ${error.message}`), type: "error", }); setCurrentEventTrackerState(currentEventTracker); From f0f21dc8f90931ca12f14e740531b87684ce7713 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 12:56:34 +0200 Subject: [PATCH 19/24] Improve messages to user --- i18n/en.pot | 6 +++--- i18n/es.po | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index ae5f2314..8786dc3f 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-10-09T10:17:08.902Z\n" -"PO-Revision-Date: 2024-10-09T10:17:08.902Z\n" +"POT-Creation-Date: 2024-10-09T10:56:24.909Z\n" +"PO-Revision-Date: 2024-10-09T10:56:24.909Z\n" msgid "Low" msgstr "" @@ -162,7 +162,7 @@ msgstr "" msgid "Add another" msgstr "" -msgid "Create Event form cannot be loaded" +msgid "Form cannot be loaded" msgstr "" msgid "Disease Outbreak saved successfully" diff --git a/i18n/es.po b/i18n/es.po index dea17a7c..12778856 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-10-09T10:17:08.902Z\n" +"POT-Creation-Date: 2024-10-09T10:56:24.909Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -161,7 +161,7 @@ msgstr "" msgid "Add another" msgstr "" -msgid "Create Event form cannot be loaded" +msgid "Form cannot be loaded" msgstr "" msgid "Disease Outbreak saved successfully" From 135d7ead91ab79fd7a94ed1036edaea493118e01 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 9 Oct 2024 17:01:26 +0200 Subject: [PATCH 20/24] Open event in new tab and fix 717 performance in even tracker page --- .../PerformanceOverviewD2Repository.ts | 45 ++++++++++++++++--- .../test/PerformanceOverviewTestRepository.ts | 5 ++- .../PerformanceOverviewRepository.ts | 3 +- .../usecases/Get717PerformanceUseCase.ts | 12 +++-- .../table/statistic-table/StatisticTable.tsx | 30 ++++++++++--- src/webapp/hooks/useRoutes.ts | 21 +++++++-- src/webapp/pages/dashboard/DashboardPage.tsx | 10 ----- .../pages/dashboard/use717Performance.ts | 35 ++++++++++----- .../pages/event-tracker/EventTrackerPage.tsx | 7 ++- 9 files changed, 124 insertions(+), 44 deletions(-) diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index e5e770a1..1d544278 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -26,6 +26,7 @@ import { } from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; import { OrgUnit } from "../../domain/entities/OrgUnit"; +import { Id } from "../../domain/entities/Ref"; const formatDate = (date: Date): string => { const year = date.getFullYear(); @@ -246,16 +247,37 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos }); } - getEventTracker717Performance(): FutureData { + getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData { return apiToFuture( - this.api.analytics.get({ - dimension: [`dx:${EVENT_TRACKER_717_IDS.map(({ id }) => id).join(";")}`], + 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, - includeMetadataDetails: true, }) - ).map(res => { - return this.mapIndicatorsTo717PerformanceMetrics(res.rows, EVENT_TRACKER_717_IDS); + ).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 Future.success( + this.mapIndicatorsTo717PerformanceMetrics( + mappedIndicatorsToRows, + EVENT_TRACKER_717_IDS + ) + ); }); } @@ -314,3 +336,14 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos }, {} as Partial); } } + +function filterAnalyticsEnrollmentDataByDiseaseOutbreakEvent( + diseaseOutbreakEventId: Id, + rows: string[][], + headers: { name: string; column: string }[] +): string[] | undefined { + return rows.filter(row => { + const teiId = row[headers.findIndex(header => header.name === "tei")]; + return teiId === diseaseOutbreakEventId; + })[0]; +} diff --git a/src/data/repositories/test/PerformanceOverviewTestRepository.ts b/src/data/repositories/test/PerformanceOverviewTestRepository.ts index 869abeef..c56a2a95 100644 --- a/src/data/repositories/test/PerformanceOverviewTestRepository.ts +++ b/src/data/repositories/test/PerformanceOverviewTestRepository.ts @@ -1,10 +1,13 @@ import { PerformanceMetrics717 } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { Future } from "../../../domain/entities/generic/Future"; +import { Id } from "../../../domain/entities/Ref"; import { PerformanceOverviewRepository } from "../../../domain/repositories/PerformanceOverviewRepository"; import { FutureData } from "../../api-futures"; export class PerformanceOverviewTestRepository implements PerformanceOverviewRepository { - getEventTracker717Performance(): FutureData { + getEventTracker717Performance( + _diseaseOutbreakEventId: Id + ): FutureData { return Future.success([]); } getTotalCardCounts(): FutureData { diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index 7818b8f3..1f37a4ba 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -5,6 +5,7 @@ import { PerformanceOverviewMetrics, PerformanceMetrics717, } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { Id } from "../entities/Ref"; export interface PerformanceOverviewRepository { getPerformanceOverviewMetrics( @@ -17,5 +18,5 @@ export interface PerformanceOverviewRepository { dateRangeFilter?: string[] ): FutureData; getDashboard717Performance(): FutureData; - getEventTracker717Performance(): FutureData; + getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData; } diff --git a/src/domain/usecases/Get717PerformanceUseCase.ts b/src/domain/usecases/Get717PerformanceUseCase.ts index 27af91ed..bdd78f59 100644 --- a/src/domain/usecases/Get717PerformanceUseCase.ts +++ b/src/domain/usecases/Get717PerformanceUseCase.ts @@ -1,5 +1,6 @@ import { FutureData } from "../../data/api-futures"; import { PerformanceMetrics717 } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { Id } from "../entities/Ref"; import { PerformanceOverviewRepository } from "../repositories/PerformanceOverviewRepository"; export class Get717PerformanceUseCase { @@ -9,9 +10,14 @@ export class Get717PerformanceUseCase { } ) {} - public execute(type: "dashboard" | "event_tracker"): FutureData { - if (type === "event_tracker") { - return this.options.performanceOverviewRepository.getEventTracker717Performance(); + public execute( + type: "dashboard" | "event_tracker", + diseaseOutbreakEventId: Id | undefined + ): FutureData { + if (type === "event_tracker" && diseaseOutbreakEventId) { + return this.options.performanceOverviewRepository.getEventTracker717Performance( + diseaseOutbreakEventId + ); } else if (type === "dashboard") { return this.options.performanceOverviewRepository.getDashboard717Performance(); } else throw new Error(`Unknown 717 type: ${type} `); diff --git a/src/webapp/components/table/statistic-table/StatisticTable.tsx b/src/webapp/components/table/statistic-table/StatisticTable.tsx index 15b33579..893f361c 100644 --- a/src/webapp/components/table/statistic-table/StatisticTable.tsx +++ b/src/webapp/components/table/statistic-table/StatisticTable.tsx @@ -19,9 +19,10 @@ 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"; +import { Link } from "react-router-dom"; +import { RouteName, useRoutes } from "../../../hooks/useRoutes"; export type TableColumn = { value: string; @@ -53,7 +54,6 @@ export type StatisticTableProps = { filters: FiltersConfig[]; order: Maybe; setOrder: Dispatch>>; - goToEvent: (id: Maybe) => void; }; export const StatisticTable: React.FC = React.memo( @@ -65,8 +65,9 @@ export const StatisticTable: React.FC = React.memo( filters: filtersConfig, order, setOrder, - goToEvent, }) => { + const { generatePath } = useRoutes(); + const calculateColumns = [...editRiskAssessmentColumns, ...Object.keys(columnRules)]; const { searchTerm, setSearchTerm, filters, setFilters, filteredRows, filterOptions } = @@ -151,11 +152,21 @@ export const StatisticTable: React.FC = React.memo( /> ) : ( goToEvent(row.id)} key={`${rowIndex}-${column.value}`} $link={columnIndex === 0} > - {row[column.value] || ""} + {row.id ? ( + + {row[column.value]} + + ) : ( + row[column.value] + )} ) )} @@ -218,6 +229,15 @@ const StyledTableCell = styled(TableCell)<{ $link?: boolean }>` font-weight: ${props => (props.$link ? "600" : "initial")}; `; +const StyledLink = styled(Link)<{ $link?: boolean }>` + text-decoration: ${props => (props.$link ? "underline" : "none")}; + cursor: ${props => (props.$link ? "pointer" : "initial")}; + font-weight: ${props => (props.$link ? "600" : "initial")}; + color: ${props => props.theme.palette.text.primary}; + width: 100%; + height: 100%; +`; + const Container = styled.div` display: flex; justify-content: space-between; diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index f7285bd8..bfbcc61a 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -1,6 +1,6 @@ import React from "react"; import { join } from "string-ts"; -import { generatePath, useHistory } from "react-router-dom"; +import { generatePath as generatePathRRD, useHistory } from "react-router-dom"; import { FormType } from "../pages/form-page/FormPage"; @@ -43,16 +43,29 @@ type RouteParams = { [RouteName.DASHBOARD]: undefined; }; -export function useRoutes() { +type State = { + goTo: (route: T, params?: RouteParams[T]) => void; + generatePath: (route: T, params?: RouteParams[T]) => string; +}; + +export function useRoutes(): State { const history = useHistory(); const goTo = React.useCallback( (route: T, params?: RouteParams[T]) => { - const path = generatePath(routes[route], params as any); + const path = generatePathRRD(routes[route], params as any); history.push(path); }, [history] ); - return { goTo }; + const generatePath = React.useCallback( + (route: T, params?: RouteParams[T]) => { + const path = generatePathRRD(routes[route], params as any); + return path; + }, + [] + ); + + return { goTo, generatePath }; } diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 48b39450..600012b7 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -9,10 +9,7 @@ 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 { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; -import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { useAlertsActiveVerifiedFilters } from "./useAlertsActiveVerifiedFilters"; import { MapSection } from "../../components/map/MapSection"; import { Selector } from "../../components/selector/Selector"; @@ -49,7 +46,6 @@ export const DashboardPage: React.FC = React.memo(() => { dateRangeFilter.value ); - const { goTo } = useRoutes(); const { resetCurrentEventTracker: resetCurrentEventTrackerId } = useCurrentEventTracker(); const { lastAnalyticsRuntime } = useLastAnalyticsRuntime(); @@ -58,11 +54,6 @@ export const DashboardPage: React.FC = React.memo(() => { resetCurrentEventTrackerId(); }); - const goToEvent = (id: Maybe) => { - if (!id) return; - goTo(RouteName.EVENT_TRACKER, { id }); - }; - return performanceOverviewLoading || _717CardsLoading || cardCountsLoading ? ( ) : ( @@ -158,7 +149,6 @@ export const DashboardPage: React.FC = React.memo(() => { setOrder={setOrder} columnRules={columnRules} editRiskAssessmentColumns={editRiskAssessmentColumns} - goToEvent={goToEvent} />
diff --git a/src/webapp/pages/dashboard/use717Performance.ts b/src/webapp/pages/dashboard/use717Performance.ts index 3c8da6dd..4fe86186 100644 --- a/src/webapp/pages/dashboard/use717Performance.ts +++ b/src/webapp/pages/dashboard/use717Performance.ts @@ -3,6 +3,7 @@ import { useAppContext } from "../../contexts/app-context"; import _ from "../../../domain/entities/generic/Collection"; import { StatsCardProps } from "../../components/stats-card/StatsCard"; import { PerformanceMetrics717 } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { Id } from "../../../domain/entities/Ref"; type CardColors = StatsCardProps["color"]; @@ -19,7 +20,10 @@ export type PerformanceMetric717State = { export type Order = { name: string; direction: "asc" | "desc" }; -export function use717Performance(type: "dashboard" | "event_tracker"): PerformanceMetric717State { +export function use717Performance( + type: "dashboard" | "event_tracker", + diseaseOutbreakEventId?: Id +): PerformanceMetric717State { const { compositionRoot } = useAppContext(); const [performanceMetrics717, setPerformanceMetrics717] = useState([]); @@ -68,17 +72,24 @@ export function use717Performance(type: "dashboard" | "event_tracker"): Performa useEffect(() => { setIsLoading(true); - compositionRoot.performanceOverview.get717Performance.execute(type).run( - performanceMetrics717 => { - setPerformanceMetrics717(transformData(performanceMetrics717)); - setIsLoading(false); - }, - error => { - console.error({ error }); - setIsLoading(false); - } - ); - }, [compositionRoot.performanceOverview.get717Performance, transformData, type]); + compositionRoot.performanceOverview.get717Performance + .execute(type, diseaseOutbreakEventId) + .run( + performanceMetrics717 => { + setPerformanceMetrics717(transformData(performanceMetrics717)); + setIsLoading(false); + }, + error => { + console.error({ error }); + setIsLoading(false); + } + ); + }, [ + compositionRoot.performanceOverview.get717Performance, + diseaseOutbreakEventId, + transformData, + type, + ]); return { performanceMetrics717, diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 8c86fe20..b171a487 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -56,8 +56,11 @@ export const EventTrackerPage: React.FC = React.memo(() => { formType: "risk-assessment-summary", }); }, [goTo]); - const { performanceMetrics717, isLoading: _717CardsLoading } = - use717Performance("event_tracker"); + + const { performanceMetrics717, isLoading: _717CardsLoading } = use717Performance( + "event_tracker", + id + ); useEffect(() => { if (eventTrackerDetails) changeCurrentEventTrackerId(eventTrackerDetails); From 8f40b4387bfd7cc65606d29f482e83852f0c981f Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 10 Oct 2024 11:15:22 +0200 Subject: [PATCH 21/24] Fix build tree and add search bar --- .../im-team-hierarchy/IMTeamHierarchyItem.tsx | 29 +++-- .../im-team-hierarchy/IMTeamHierarchyView.tsx | 33 +++-- .../IMTeamBuilderPage.tsx | 4 + .../useIMTeamBuilder.ts | 113 +++++++++++++----- 4 files changed, 122 insertions(+), 57 deletions(-) diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx index 7913f315..a80300c9 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyItem.tsx @@ -7,16 +7,18 @@ import { Maybe } from "../../../utils/ts-utils"; import { Checkbox } from "../checkbox/Checkbox"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TeamMemberProfile } from "./TeamMemberProfile"; +import { IMTeamHierarchyOption } from "./IMTeamHierarchyView"; +import { Id } from "../../../domain/entities/Ref"; type IMTeamHierarchyItemProps = { nodeId: string; teamRole: string; member: Maybe; - selected: boolean; disabled?: boolean; onSelectedChange: (nodeId: string, selected: boolean) => void; - children?: React.ReactNode; + subChildren: IMTeamHierarchyOption[]; diseaseOutbreakEventName: string; + selectedItemId: Id; }; export const IMTeamHierarchyItem: React.FC = React.memo(props => { @@ -26,9 +28,9 @@ export const IMTeamHierarchyItem: React.FC = React.mem member, disabled = false, onSelectedChange, - selected, - children, + subChildren, diseaseOutbreakEventName, + selectedItemId, } = props; const [openMemberProfile, setOpenMemberProfile] = React.useState(false); @@ -43,9 +45,9 @@ export const IMTeamHierarchyItem: React.FC = React.mem const onTeamRoleClick = React.useCallback( (event: React.MouseEvent) => { event.preventDefault(); - !disabled && onSelectedChange(nodeId, !selected); + !disabled && onSelectedChange(nodeId, !(selectedItemId === nodeId)); }, - [disabled, nodeId, onSelectedChange, selected] + [disabled, nodeId, onSelectedChange, selectedItemId] ); const onMemberClick = React.useCallback( @@ -67,7 +69,7 @@ export const IMTeamHierarchyItem: React.FC = React.mem @@ -84,7 +86,18 @@ export const IMTeamHierarchyItem: React.FC = React.mem } > - {children} + {subChildren.map(child => ( + + ))} {member && ( diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx index a2ce3b9b..fad574f6 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx @@ -7,13 +7,14 @@ import { Maybe } from "../../../utils/ts-utils"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { IMTeamHierarchyItem } from "./IMTeamHierarchyItem"; import { Id } from "../../../domain/entities/Ref"; +import { SearchInput } from "../search-input/SearchInput"; export type IMTeamHierarchyOption = { id: Id; teamRole: string; teamRoleId: Id; member: Maybe; - parents: { id: Id; name: string }[]; + parent: Maybe; children: IMTeamHierarchyOption[]; }; @@ -22,13 +23,23 @@ type IMTeamHierarchyViewProps = { selectedItemId: Id; onSelectedItemChange: (nodeId: Id, selected: boolean) => void; diseaseOutbreakEventName: string; + onSearchChange: (term: string) => void; + searchTerm: string; }; export const IMTeamHierarchyView: React.FC = React.memo(props => { - const { onSelectedItemChange, items, selectedItemId, diseaseOutbreakEventName } = props; + const { + onSelectedItemChange, + items, + selectedItemId, + diseaseOutbreakEventName, + searchTerm, + onSearchChange, + } = props; return ( + } @@ -40,23 +51,11 @@ export const IMTeamHierarchyView: React.FC = React.mem nodeId={item.id} teamRole={item.teamRole} member={item.member} - selected={selectedItemId === item.id} + selectedItemId={selectedItemId} onSelectedChange={onSelectedItemChange} diseaseOutbreakEventName={diseaseOutbreakEventName} - > - {item.children && - item.children.map(child => ( - - ))} - + subChildren={item.children} + /> ))} diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 5f9f4082..a2a5c4d4 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -30,6 +30,8 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { selectedHierarchyItemId, openDeleteModalData, disableDeletion, + searchTerm, + onSearchChange, onSelectHierarchyItem, goToIncidentManagementTeamRole, onDeleteIncidentManagementTeamMember, @@ -88,6 +90,8 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { selectedItemId={selectedHierarchyItemId} onSelectedItemChange={onSelectHierarchyItem} diseaseOutbreakEventName={getCurrentEventTracker()?.name || ""} + onSearchChange={onSearchChange} + searchTerm={searchTerm} /> void; disableDeletion: boolean; + onSearchChange: (term: string) => void; + searchTerm: string; }; export function useIMTeamBuilder(id: Id): State { @@ -51,6 +54,7 @@ export function useIMTeamBuilder(id: Id): State { const [openDeleteModalData, setOpenDeleteModalData] = useState( undefined ); + const [searchTerm, setSearchTerm] = useState(""); const getIncidentManagementTeam = useCallback(() => { compositionRoot.incidentManagementTeam.get.execute(id).run( @@ -58,7 +62,7 @@ export function useIMTeamBuilder(id: Id): State { setIncidentManagementTeam(incidentManagementTeam); setIncidentManagementTeamHierarchyItems( mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( - incidentManagementTeam + incidentManagementTeam?.teamHierarchy ) ); }, @@ -198,6 +202,28 @@ export function useIMTeamBuilder(id: Id): State { } }, [incidentManagementTeam?.teamHierarchy]); + const onSearchChange = useCallback( + (term: string) => { + setSearchTerm(term); + + if (incidentManagementTeamHierarchyItems) { + const filteredIncidentManagementTeamHierarchyItems = term + ? filterIncidentManagementTeamHierarchy( + incidentManagementTeamHierarchyItems, + term + ) + : mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( + incidentManagementTeam?.teamHierarchy + ); + + setIncidentManagementTeamHierarchyItems( + filteredIncidentManagementTeamHierarchyItems + ); + } + }, + [incidentManagementTeam?.teamHierarchy, incidentManagementTeamHierarchyItems] + ); + const lastUpdated = getDateAsLocaleDateTimeString(new Date()); //TO DO : Fetch sync time from datastore once implemented return { @@ -212,13 +238,15 @@ export function useIMTeamBuilder(id: Id): State { openDeleteModalData, onOpenDeleteModalData, disableDeletion, + searchTerm, + onSearchChange, }; } function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( - incidentManagementTeam: Maybe + incidentManagementTeamHierarchy: Maybe ): IMTeamHierarchyOption[] { - if (incidentManagementTeam?.teamHierarchy) { + if (incidentManagementTeamHierarchy) { const createHierarchyItem = ( item: TeamMember, teamRole: TeamRole @@ -237,11 +265,11 @@ function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( teamRoles: item.teamRoles, workPosition: item.workPosition, }), - parents: [], + parent: teamRole.reportsToUsername, children: [], }); - const teamMap = incidentManagementTeam?.teamHierarchy.reduce< + const teamMap = incidentManagementTeamHierarchy.reduce< Record >((map, item) => { const hierarchyItems = item.teamRoles?.map(teamRole => @@ -259,34 +287,55 @@ function mapIncidentManagementTeamToIncidentManagementTeamHierarchyItems( ); }, {}); - return incidentManagementTeam.teamHierarchy.reduce((acc, item) => { - return item.teamRoles - ? item.teamRoles.reduce((innerAcc, teamRole) => { - const hierarchyItem = teamMap[teamRole.id]; - if (!hierarchyItem) return innerAcc; - - const reportsToUsername = teamRole.reportsToUsername; - if (reportsToUsername) { - const parentItem = Object.values(teamMap).find( - teamItem => teamItem.member?.username === reportsToUsername - ); - - if (parentItem) { - parentItem.children = [...(parentItem.children || []), hierarchyItem]; - hierarchyItem.parents = [ - ...hierarchyItem.parents, - { id: parentItem.id, name: parentItem.teamRole }, - ]; - } - } - - return hierarchyItem.parents.length === 0 - ? [...innerAcc, hierarchyItem] - : innerAcc; - }, acc) - : acc; - }, []); + return buildTree(teamMap); } else { return []; } } + +function buildTree(teamMap: Record): IMTeamHierarchyOption[] { + const findChildren = (parentUsername: string): IMTeamHierarchyOption[] => + Object.values(teamMap) + .filter(item => item.parent === parentUsername) + .reduce((acc, item) => { + const children = findChildren(item.member?.username || ""); + return [...acc, { ...item, children: [...item.children, ...children] }]; + }, []); + + return Object.values(teamMap).reduce((acc, item) => { + const isRoot = !item.parent; + if (isRoot) { + const children = findChildren(item.member?.username || ""); + return [...acc, { ...item, children: [...item.children, ...children] }]; + } + + return acc; + }, []); +} + +function filterIncidentManagementTeamHierarchy( + items: IMTeamHierarchyOption[], + searchTerm: string +): IMTeamHierarchyOption[] { + return _c( + items.map(item => { + const filteredChildren = filterIncidentManagementTeamHierarchy( + item.children, + searchTerm + ); + + const isMatch = item.teamRole.toLowerCase().includes(searchTerm.toLowerCase()); + + if (isMatch || filteredChildren.length > 0) { + return { + ...item, + children: filteredChildren, + }; + } + + return null; + }) + ) + .compact() + .toArray(); +} From a1334f8d2d5a27534fab57b3416282e5fcfe55f7 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 10 Oct 2024 14:45:57 +0200 Subject: [PATCH 22/24] Improve UI and remove dependency with role codes hardcoded --- .../IncidentManagementTeamD2Repository.ts | 177 ++++++++---------- src/data/repositories/RoleD2Repository.ts | 87 +++++++-- .../IncidentManagementTeamBuilderConstants.ts | 42 +---- .../IncidentManagementTeamTestRepository.ts | 20 +- .../utils/IncidentManagementTeamMapper.ts | 115 ++++-------- src/domain/entities/ConfigurableForm.ts | 3 +- .../IncidentManagementTeamRepository.ts | 16 +- ...IncidentManagementTeamMemberRoleUseCase.ts | 15 +- .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 12 +- .../GetIncidentManagementTeamByIdUseCase.ts | 8 +- src/domain/usecases/SaveEntityUseCase.ts | 13 +- .../disease-outbreak/SaveDiseaseOutbreak.ts | 49 +++-- .../GetIncidentManagementTeamById.ts | 20 +- .../GetIncidentManagementTeamWithOptions.ts | 14 +- .../im-team-hierarchy/IMTeamHierarchyView.tsx | 46 +++-- ...tManagementTeamMemberToInitialFormState.ts | 27 +-- .../form-page/mapFormStateToEntityData.ts | 9 +- .../useIMTeamBuilder.ts | 12 +- 18 files changed, 346 insertions(+), 339 deletions(-) diff --git a/src/data/repositories/IncidentManagementTeamD2Repository.ts b/src/data/repositories/IncidentManagementTeamD2Repository.ts index bea23b44..c479234c 100644 --- a/src/data/repositories/IncidentManagementTeamD2Repository.ts +++ b/src/data/repositories/IncidentManagementTeamD2Repository.ts @@ -19,54 +19,51 @@ import { } from "./utils/IncidentManagementTeamMapper"; import { TeamMember, TeamRole } from "../../domain/entities/incident-management-team/TeamMember"; import { getProgramStage } from "./utils/MetadataHelper"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; import { assertOrError } from "./utils/AssertOrError"; +import { Role } from "../../domain/entities/incident-management-team/Role"; export class IncidentManagementTeamD2Repository implements IncidentManagementTeamRepository { constructor(private api: D2Api) {} get( diseaseOutbreakId: Id, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData> { - return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( - ({ dataElementRoles, events }) => { - const maybeIncidentManagementTeam: Maybe = - mapD2EventsToIncidentManagementTeam(events, dataElementRoles, teamMembers); - return Future.success(maybeIncidentManagementTeam); - } - ); + return this.getIncidentManagementTeamEvents(diseaseOutbreakId).flatMap(d2Events => { + return Future.success( + mapD2EventsToIncidentManagementTeam(d2Events, roles, teamMembers) + ); + }); } - getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData { - return this.getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId).flatMap( - ({ dataElementRoles, events }) => { - return apiToFuture( - this.api.metadata.get({ - users: { - fields: d2UserFields, - filter: { username: { eq: username } }, - }, - }) + getIncidentManagementTeamMember( + username: Id, + diseaseOutbreakId: Id, + roles: Role[] + ): FutureData { + return this.getIncidentManagementTeamEvents(diseaseOutbreakId).flatMap(d2Events => { + return apiToFuture( + this.api.metadata.get({ + users: { + fields: d2UserFields, + filter: { username: { eq: username } }, + }, + }) + ) + .flatMap(response => + assertOrError(response.users[0], "Incident Management Team Member") ) - .flatMap(response => - assertOrError(response.users[0], "Incident Management Team Member") - ) - .map(d2User => - this.mapUserToIncidentManagementTeamMember( - d2User as D2UserFix, - events, - dataElementRoles - ) - ); - } - ); + .map(d2User => + this.mapUserToIncidentManagementTeamMember(d2User as D2UserFix, d2Events, roles) + ); + }); } private mapUserToIncidentManagementTeamMember( d2User: D2UserFix, events: D2TrackerEvent[], - dataElementRoles: D2DataElement[] + roles: Role[] ): TeamMember { const avatarId = d2User?.avatar?.id; const photoUrlString = avatarId @@ -88,11 +85,7 @@ export class IncidentManagementTeamD2Repository implements IncidentManagementTea workPosition: undefined, // TODO: Get workPosition when defined }); - const teamRoles = getTeamMemberIncidentManagementTeamRoles( - teamMember, - events, - dataElementRoles - ); + const teamRoles = getTeamMemberIncidentManagementTeamRoles(teamMember, events, roles); return new TeamMember({ ...teamMember, @@ -103,80 +96,75 @@ export class IncidentManagementTeamD2Repository implements IncidentManagementTea saveIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData { - return this.saveOrDeleteIncidentManagementTeamMember( + return this.saveOrDeleteIncidentManagementTeamMember({ teamMemberRole, incidentManagementTeamMember, diseaseOutbreakId, - "CREATE_AND_UPDATE" - ); + importStrategy: "CREATE_AND_UPDATE", + roles, + }); } deleteIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData { - return this.saveOrDeleteIncidentManagementTeamMember( + return this.saveOrDeleteIncidentManagementTeamMember({ teamMemberRole, incidentManagementTeamMember, diseaseOutbreakId, - "DELETE" - ); + importStrategy: "DELETE", + roles, + }); } - private getDataElementRolesAndIncidentManagementTeamEvents(diseaseOutbreakId: Id): FutureData<{ - dataElementRoles: D2DataElement[]; - events: D2TrackerEvent[]; - }> { - return Future.joinObj( - { - dataElementRoles: apiToFuture( - this.api.models.dataElements.get({ - fields: dataElementFields, - paging: false, - filter: { - id: { - in: Object.values( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS - ), - }, - }, - }) - ), - events: apiToFuture( - this.api.tracker.events.get({ - program: RTSL_ZEBRA_PROGRAM_ID, - orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, - trackedEntity: diseaseOutbreakId, - programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, - fields: { - dataValues: { - dataElement: { id: true, code: true }, - value: true, - }, - trackedEntity: true, - event: true, - }, - }) - ), - }, - { concurrency: 2 } - ).flatMap(({ dataElementRoles, events }) => { - return Future.success({ - dataElementRoles: dataElementRoles.objects, - events: events.instances, + private getIncidentManagementTeamEvents(diseaseOutbreakId: Id): FutureData { + return apiToFuture( + this.api.tracker.events.get({ + program: RTSL_ZEBRA_PROGRAM_ID, + orgUnit: RTSL_ZEBRA_ORG_UNIT_ID, + trackedEntity: diseaseOutbreakId, + programStage: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + fields: { + dataValues: { + dataElement: { id: true, code: true }, + value: true, + }, + trackedEntity: true, + event: true, + }, + }) + ) + .flatMap(response => + assertOrError( + response.instances, + `Incident management team builder program stage not found` + ) + ) + .flatMap(d2Events => { + return Future.success(d2Events); }); - }); } - private saveOrDeleteIncidentManagementTeamMember( - teamMemberRole: TeamRole, - incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id, - importStrategy: "CREATE_AND_UPDATE" | "DELETE" - ): FutureData { + private saveOrDeleteIncidentManagementTeamMember(params: { + teamMemberRole: TeamRole; + incidentManagementTeamMember: TeamMember; + diseaseOutbreakId: Id; + importStrategy: "CREATE_AND_UPDATE" | "DELETE"; + roles: Role[]; + }): FutureData { + const { + teamMemberRole, + incidentManagementTeamMember, + diseaseOutbreakId, + importStrategy, + roles, + } = params; return getProgramStage( this.api, RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID @@ -209,7 +197,8 @@ export class IncidentManagementTeamD2Repository implements IncidentManagementTea incidentManagementTeamMember, diseaseOutbreakId, enrollmentId, - incidentManagementTeamBuilderDataElements + incidentManagementTeamBuilderDataElements, + roles ); return apiToFuture( diff --git a/src/data/repositories/RoleD2Repository.ts b/src/data/repositories/RoleD2Repository.ts index 90584f6c..42fd39fd 100644 --- a/src/data/repositories/RoleD2Repository.ts +++ b/src/data/repositories/RoleD2Repository.ts @@ -4,49 +4,96 @@ import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { Role } from "../../domain/entities/incident-management-team/Role"; import { RoleRepository } from "../../domain/repositories/RoleRepository"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "./consts/IncidentManagementTeamBuilderConstants"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID } from "./consts/DiseaseOutbreakConstants"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES } from "./consts/IncidentManagementTeamBuilderConstants"; export class RoleD2Repository implements RoleRepository { constructor(private api: D2Api) {} getAll(): FutureData { return apiToFuture( - this.api.models.dataElements.get({ - fields: dataElementFields, - paging: false, + this.api.models.programStages.get({ + fields: programStageFields, filter: { - id: { in: Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS) }, + id: { + eq: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_PROGRAM_STAGE_ID, + }, }, }) ) - .flatMap(response => assertOrError(response.objects, `Roles not found`)) - .flatMap(d2DataElementRoles => { - if (d2DataElementRoles.length === 0) - return Future.error(new Error(`Roles not found`)); - else - return Future.success( - d2DataElementRoles.map(d2DataElementRole => - this.mapDataElementToRole(d2DataElementRole) + .flatMap(response => + assertOrError( + response.objects, + `Incident management team builder program stage not found` + ) + ) + .flatMap(d2ProgramStages => { + const programStageDataElementsIds = + d2ProgramStages[0]?.programStageDataElements.map( + ({ dataElement }) => dataElement.id + ); + if (!programStageDataElementsIds?.length) { + return Future.error( + new Error( + `Incident management team builder program stage data elements not found` ) ); + } else { + const programStageDataElementsRoleIds = programStageDataElementsIds?.filter( + id => + id !== + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.teamMemberAssigned && + id !== + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.reportsToUsername + ); + + return apiToFuture( + this.api.models.dataElements.get({ + fields: dataElementFields, + paging: false, + filter: { + id: { + in: programStageDataElementsRoleIds, + }, + }, + }) + ) + .flatMap(response => + assertOrError( + response.objects, + `Incident management team builder data elements not found` + ) + ) + .flatMap(d2DataElements => { + return Future.success( + this.mapProgramStageDataElementsToRoles(d2DataElements) + ); + }); + } }); } - private mapDataElementToRole(d2DataElementRole: D2DataElement): Role { - return { - id: d2DataElementRole.id, - code: d2DataElementRole.code, - name: d2DataElementRole.name, - }; + private mapProgramStageDataElementsToRoles(d2DataElements: D2DataElement[]): Role[] { + return d2DataElements.map(dataElement => ({ + id: dataElement.id, + name: dataElement.name, + code: dataElement.code, + })); } } +const programStageFields = { + programStageDataElements: { + dataElement: { id: true }, + }, +} as const; + const dataElementFields = { id: true, code: true, name: true, } as const; -type D2DataElement = MetadataPick<{ +export type D2DataElement = MetadataPick<{ dataElements: { fields: typeof dataElementFields }; }>["dataElements"][number]; diff --git a/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts index c43a079d..77c081d1 100644 --- a/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts +++ b/src/data/repositories/consts/IncidentManagementTeamBuilderConstants.ts @@ -1,47 +1,11 @@ -import { GetValue } from "../../../utils/ts-utils"; +export const INCIDENT_MANAGER_ROLE = "fnZ7EcG5CCV"; -export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS = { - incidentManagerRole: "fnZ7EcG5CCV", - caseManagementRole: "Ci2TwQIVR2x", - ipcUnitLeadRole: "NARFizS9nsk", - labUnitLeadRole: "SsXKTkPrJt9", - operationalSectionLeadRole: "lO197QfYLBc", - surveillanceUnitLeadRole: "EnmRCZYjSV6", - vaccineUnitRole: "RMqPVOnz8ja", -} as const; - -export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS = { +export const RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES = { teamMemberAssigned: "iodfsSspCov", reportsToUsername: "TFIPHJyXN6H", - incidentManagerRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, - caseManagementRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole, - ipcUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole, - labUnitLeadRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole, - operationalSectionLeadRole: - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole, - surveillanceUnitLeadRole: - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole, - vaccineUnitRole: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole, } as const; -export const incidentManagementTeamBuilderCodes = { +export const incidentManagementTeamBuilderCodesWithoutRoles = { teamMemberAssigned: "RTSL_ZEB_DET_IMB_TMA", reportsToUsername: "RTSL_ZEB_DET_IMB_REPORTS", - incidentManagerRole: "RTSL_ZEB_DET_IMB_INCIDENT_MANAGER", - caseManagementRole: "RTSL_ZEB_DET_IMB_CASE_MANAGMENT", - ipcUnitLeadRole: "RTSL_ZEB_DET_IMB_IPC_LEAD", - labUnitLeadRole: "RTSL_ZEB_DET_IMB_LAB_LEAD", - operationalSectionLeadRole: "RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD", - surveillanceUnitLeadRole: "RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD", - vaccineUnitRole: "RTSL_ZEB_DET_IMB_VACCINE_UNIT", } as const; - -export type IncidentManagementTeamBuilderCodes = GetValue< - typeof incidentManagementTeamBuilderCodes ->; - -export function isStringInIncidentManagementTeamBuilderCodes( - code: string -): code is IncidentManagementTeamBuilderCodes { - return (Object.values(incidentManagementTeamBuilderCodes) as string[]).includes(code); -} diff --git a/src/data/repositories/test/IncidentManagementTeamTestRepository.ts b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts index ed2db170..8782b3ff 100644 --- a/src/data/repositories/test/IncidentManagementTeamTestRepository.ts +++ b/src/data/repositories/test/IncidentManagementTeamTestRepository.ts @@ -1,16 +1,18 @@ import { Future } from "../../../domain/entities/generic/Future"; import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; +import { Role } from "../../../domain/entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; import { Id } from "../../../domain/entities/Ref"; import { IncidentManagementTeamRepository } from "../../../domain/repositories/IncidentManagementTeamRepository"; import { Maybe } from "../../../utils/ts-utils"; import { FutureData } from "../../api-futures"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../consts/IncidentManagementTeamBuilderConstants"; export class IncidentManagementTeamTestRepository implements IncidentManagementTeamRepository { get( _diseaseOutbreakId: Id, - _teamMembers: TeamMember[] + _teamMembers: TeamMember[], + _roles: Role[] ): FutureData> { return Future.success(undefined); } @@ -18,7 +20,8 @@ export class IncidentManagementTeamTestRepository implements IncidentManagementT saveIncidentManagementTeamMemberRole( _teamMemberRole: TeamRole, _incidentManagementTeamMember: TeamMember, - _diseaseOutbreakId: Id + _diseaseOutbreakId: Id, + _roles: Role[] ): FutureData { return Future.success(undefined); } @@ -26,12 +29,17 @@ export class IncidentManagementTeamTestRepository implements IncidentManagementT deleteIncidentManagementTeamMemberRole( _teamMemberRole: TeamRole, _incidentManagementTeamMember: TeamMember, - _diseaseOutbreakId: Id + _diseaseOutbreakId: Id, + _roles: Role[] ): FutureData { return Future.success(undefined); } - getIncidentManagementTeamMember(username: Id, _diseaseOutbreakId: Id): FutureData { + getIncidentManagementTeamMember( + username: Id, + _diseaseOutbreakId: Id, + _roles: Role[] + ): FutureData { const teamMember: TeamMember = new TeamMember({ id: username, username: username, @@ -42,7 +50,7 @@ export class IncidentManagementTeamTestRepository implements IncidentManagementT { id: "role", name: "role", - roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + roleId: INCIDENT_MANAGER_ROLE, reportsToUsername: "reportsToUsername", }, ], diff --git a/src/data/repositories/utils/IncidentManagementTeamMapper.ts b/src/data/repositories/utils/IncidentManagementTeamMapper.ts index 3bafec72..8c511a5d 100644 --- a/src/data/repositories/utils/IncidentManagementTeamMapper.ts +++ b/src/data/repositories/utils/IncidentManagementTeamMapper.ts @@ -9,29 +9,24 @@ import { } from "../consts/DiseaseOutbreakConstants"; import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; import { Maybe } from "../../../utils/ts-utils"; -import { D2DataElement } from "../IncidentManagementTeamD2Repository"; import _c from "../../../domain/entities/generic/Collection"; import { Id } from "../../../domain/entities/Ref"; import { SelectedPick } from "@eyeseetea/d2-api/api"; import { D2DataElementSchema } from "@eyeseetea/d2-api/2.36"; -import { - IncidentManagementTeamBuilderCodes, - isStringInIncidentManagementTeamBuilderCodes, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, -} from "../consts/IncidentManagementTeamBuilderConstants"; +import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES } from "../consts/IncidentManagementTeamBuilderConstants"; +import { Role } from "../../../domain/entities/incident-management-team/Role"; export function mapD2EventsToIncidentManagementTeam( - events: D2TrackerEvent[], - dataElementRoles: D2DataElement[], + d2Events: D2TrackerEvent[], + roles: Role[], teamMembers: TeamMember[] ): Maybe { const teamHierarchy: TeamMember[] = teamMembers.reduce( (acc: TeamMember[], teamMember: TeamMember) => { - const memberRoleEvents = events.filter(event => { + const memberRoleEvents = d2Events.filter(event => { const teamMemberAssignedUsername = getValueById( event.dataValues, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.teamMemberAssigned ); return teamMemberAssignedUsername === teamMember.username; }); @@ -42,8 +37,9 @@ export function mapD2EventsToIncidentManagementTeam( const teamRoles = getTeamMemberIncidentManagementTeamRoles( teamMember, memberRoleEvents, - dataElementRoles + roles ); + return teamRoles.length === 0 ? acc : [...acc, new TeamMember({ ...teamMember, teamRoles: teamRoles })]; @@ -60,47 +56,42 @@ export function mapD2EventsToIncidentManagementTeam( export function getTeamMemberIncidentManagementTeamRoles( teamMemberAssigned: TeamMember, events: D2TrackerEvent[], - dataElementRoles: D2DataElement[] + roles: Role[] ): TeamRole[] { return events.reduce((acc: TeamRole[], event: D2TrackerEvent) => { if ( teamMemberAssigned.username === getValueById( event.dataValues, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.teamMemberAssigned + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.teamMemberAssigned ) ) { - const teamRole = getTeamRole(event.event, event.dataValues, dataElementRoles); + const teamRole = getTeamRole(event.event, event.dataValues, roles); + return teamRole ? [...acc, teamRole] : acc; } return acc; }, []); } -function getTeamRole( - eventId: Id, - dataValues: DataValue[], - dataElementRoles: D2DataElement[] -): Maybe { - const roleIds = Object.values(RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS); - - const selectedRoleId = roleIds.find(roleId => { - const role = getValueById(dataValues, roleId); +function getTeamRole(eventId: Id, dataValues: DataValue[], roles: Role[]): Maybe { + const selectedRoleId = roles.find(({ id }) => { + const role = getValueById(dataValues, id); return role === "true"; - }); + })?.id; - const roleDataElement = dataElementRoles.find(dataElement => dataElement.id === selectedRoleId); + const roleSelected = roles.find(role => role.id === selectedRoleId); const reportsToUsername = getValueById( dataValues, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS.reportsToUsername + RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_IDS_WITHOUT_ROLES.reportsToUsername ); - if (selectedRoleId && roleDataElement) { + if (selectedRoleId && roleSelected) { return { id: eventId, roleId: selectedRoleId, - name: roleDataElement?.name, + name: roleSelected?.name, reportsToUsername: reportsToUsername, }; } @@ -122,19 +113,17 @@ export function mapIncidentManagementTeamMemberToD2Event( incidentManagementTeamMember: TeamMember, teiId: Id, enrollmentId: Id, - programStageDataElementsMetadata: D2ProgramStageDataElementsMetadata[] + programStageDataElementsMetadata: D2ProgramStageDataElementsMetadata[], + roles: Role[] ): D2TrackerEvent { - const dataElementValues: Record = - getValueFromIncidentManagementTeamMember( - incidentManagementTeamMember.username, - teamMemberRole - ); + const dataElementValues = getValueFromIncidentManagementTeamMember( + incidentManagementTeamMember.username, + teamMemberRole, + roles + ); const dataValues: DataValue[] = programStageDataElementsMetadata.map(programStage => { - if (!isStringInIncidentManagementTeamBuilderCodes(programStage.dataElement.code)) { - throw new Error("DataElement code not found in IncidentManagementTeamBuilderCodes"); - } - const typedCode: IncidentManagementTeamBuilderCodes = programStage.dataElement.code; + const typedCode = programStage.dataElement.code; return getPopulatedDataElement(programStage.dataElement.id, dataElementValues[typedCode]); }); @@ -155,48 +144,22 @@ export function mapIncidentManagementTeamMemberToD2Event( export function getValueFromIncidentManagementTeamMember( incidentManagementTeamMemberUsername: string, - teamRoleAssigned: TeamRole -): Record { + teamRoleAssigned: TeamRole, + roles: Role[] +): Record { const checkRoleSelected = (roleId: string): boolean => (teamRoleAssigned?.roleId || "") === roleId; + const rolesObjByCode = roles.reduce((acc, role) => { + return { + ...acc, + [role.code]: checkRoleSelected(role.id) ? "true" : "", + }; + }, {}); + return { RTSL_ZEB_DET_IMB_TMA: incidentManagementTeamMemberUsername, RTSL_ZEB_DET_IMB_REPORTS: teamRoleAssigned?.reportsToUsername ?? "", - RTSL_ZEB_DET_IMB_INCIDENT_MANAGER: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_CASE_MANAGMENT: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.caseManagementRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_IPC_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.ipcUnitLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_LAB_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.labUnitLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_OPERATIONAL_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.operationalSectionLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_SURVEILLANCE_LEAD: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.surveillanceUnitLeadRole - ) - ? "true" - : "", - RTSL_ZEB_DET_IMB_VACCINE_UNIT: checkRoleSelected( - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.vaccineUnitRole - ) - ? "true" - : "", + ...rolesObjByCode, }; } diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index beefb3b1..0649bdba 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -11,6 +11,7 @@ import { RiskAssessmentGrading } from "./risk-assessment/RiskAssessmentGrading"; import { RiskAssessmentSummary } from "./risk-assessment/RiskAssessmentSummary"; import { RiskAssessmentQuestionnaire } from "./risk-assessment/RiskAssessmentQuestionnaire"; import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; +import { Role } from "./incident-management-team/Role"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -86,7 +87,7 @@ export type RiskAssessmentQuestionnaireFormData = BaseFormData & { }; export type IncidentManagementTeamRoleOptions = { - roles: Option[]; + roles: Role[]; teamMembers: TeamMember[]; incidentManagers: TeamMember[]; }; diff --git a/src/domain/repositories/IncidentManagementTeamRepository.ts b/src/domain/repositories/IncidentManagementTeamRepository.ts index fe710da6..becbc70c 100644 --- a/src/domain/repositories/IncidentManagementTeamRepository.ts +++ b/src/domain/repositories/IncidentManagementTeamRepository.ts @@ -1,23 +1,31 @@ import { FutureData } from "../../data/api-futures"; import { Maybe } from "../../utils/ts-utils"; import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; +import { Role } from "../entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; import { Id } from "../entities/Ref"; export interface IncidentManagementTeamRepository { get( diseaseOutbreakId: Id, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData>; saveIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData; deleteIncidentManagementTeamMemberRole( teamMemberRole: TeamRole, incidentManagementTeamMember: TeamMember, - diseaseOutbreakId: Id + diseaseOutbreakId: Id, + roles: Role[] ): FutureData; - getIncidentManagementTeamMember(username: Id, diseaseOutbreakId: Id): FutureData; + getIncidentManagementTeamMember( + username: Id, + diseaseOutbreakId: Id, + roles: Role[] + ): FutureData; } diff --git a/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts index fe5c9823..b509ab8a 100644 --- a/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts +++ b/src/domain/usecases/DeleteIncidentManagementTeamMemberRoleUseCase.ts @@ -2,11 +2,13 @@ import { FutureData } from "../../data/api-futures"; import { TeamMember, TeamRole } from "../entities/incident-management-team/TeamMember"; import { Id } from "../entities/Ref"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; export class DeleteIncidentManagementTeamMemberRoleUseCase { constructor( private options: { incidentManagementTeamRepository: IncidentManagementTeamRepository; + roleRepository: RoleRepository; } ) {} @@ -15,10 +17,13 @@ export class DeleteIncidentManagementTeamMemberRoleUseCase { incidentManagementTeam: TeamMember, diseaseOutbreakId: Id ): FutureData { - return this.options.incidentManagementTeamRepository.deleteIncidentManagementTeamMemberRole( - teamMemberRole, - incidentManagementTeam, - diseaseOutbreakId - ); + return this.options.roleRepository.getAll().flatMap(roles => { + return this.options.incidentManagementTeamRepository.deleteIncidentManagementTeamMemberRole( + teamMemberRole, + incidentManagementTeam, + diseaseOutbreakId, + roles + ); + }); } } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 5c420427..d108f490 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -7,6 +7,7 @@ import { IncidentManagementTeamRepository } from "../repositories/IncidentManage import { OptionsRepository } from "../repositories/OptionsRepository"; import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; import { getAll } from "./utils/risk-assessment/GetRiskAssessmentById"; @@ -20,6 +21,7 @@ export class GetDiseaseOutbreakByIdUseCase { orgUnitRepository: OrgUnitRepository; riskAssessmentRepository: RiskAssessmentRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; + roleRepository: RoleRepository; } ) {} @@ -56,11 +58,8 @@ export class GetDiseaseOutbreakByIdUseCase { this.options.optionsRepository, this.options.teamMemberRepository ), - incidentManagementTeam: getIncidentManagementTeamById( - id, - this.options.incidentManagementTeamRepository, - this.options.teamMemberRepository - ), + incidentManagementTeam: getIncidentManagementTeamById(id, this.options), + roles: this.options.roleRepository.getAll(), }).flatMap( ({ mainSyndrome, @@ -70,9 +69,10 @@ export class GetDiseaseOutbreakByIdUseCase { areasAffectedDistricts, riskAssessment, incidentManagementTeam, + roles, }) => { return this.options.incidentManagementTeamRepository - .getIncidentManagementTeamMember(incidentManagerName, id) + .getIncidentManagementTeamMember(incidentManagerName, id, roles) .flatMap(incidentManager => { const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ diff --git a/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts index 2f994a94..53d6b658 100644 --- a/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts +++ b/src/domain/usecases/GetIncidentManagementTeamByIdUseCase.ts @@ -3,22 +3,20 @@ import { Maybe } from "../../utils/ts-utils"; import { IncidentManagementTeam } from "../entities/incident-management-team/IncidentManagementTeam"; import { Id } from "../entities/Ref"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../repositories/RoleRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { getIncidentManagementTeamById } from "./utils/incident-management-team/GetIncidentManagementTeamById"; export class GetIncidentManagementTeamByIdUseCase { constructor( private options: { + roleRepository: RoleRepository; teamMemberRepository: TeamMemberRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; } ) {} public execute(diseaseOutbreakEventId: Id): FutureData> { - return getIncidentManagementTeamById( - diseaseOutbreakEventId, - this.options.incidentManagementTeamRepository, - this.options.teamMemberRepository - ); + return getIncidentManagementTeamById(diseaseOutbreakEventId, this.options); } } diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 377c27ab..1ab20e3d 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../data/api-futures"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { ConfigurableForm } from "../entities/ConfigurableForm"; import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; @@ -9,6 +9,7 @@ import { IncidentManagementTeamRepository } from "../repositories/IncidentManage import { RiskAssessmentRepository } from "../repositories/RiskAssessmentRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbreak"; +import { RoleRepository } from "../repositories/RoleRepository"; export class SaveEntityUseCase { constructor( @@ -17,6 +18,7 @@ export class SaveEntityUseCase { riskAssessmentRepository: RiskAssessmentRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; + roleRepository: RoleRepository; } ) {} @@ -30,6 +32,7 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: this.options.incidentManagementTeamRepository, teamMemberRepository: this.options.teamMemberRepository, + roleRepository: this.options.roleRepository, }, formData.entity ); @@ -43,9 +46,7 @@ export class SaveEntityUseCase { case "incident-management-team-member-assignment": { const isIncidentManager = formData.entity.teamRoles?.find( - role => - role.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + role => role.roleId === INCIDENT_MANAGER_ROLE ); const hasIncidentManagerChanged = @@ -73,6 +74,7 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: this.options.incidentManagementTeamRepository, teamMemberRepository: this.options.teamMemberRepository, + roleRepository: this.options.roleRepository, }, updatedDiseaseOutbreakEvent ); @@ -92,7 +94,8 @@ export class SaveEntityUseCase { return this.options.incidentManagementTeamRepository.saveIncidentManagementTeamMemberRole( teamRoleToSave, formData.entity, - formData.eventTrackerDetails.id + formData.eventTrackerDetails.id, + formData.options.roles ); } } diff --git a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts index a82543d5..c43aee08 100644 --- a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts +++ b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts @@ -1,11 +1,13 @@ import { FutureData } from "../../../../data/api-futures"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { 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 { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../../../repositories/RoleRepository"; import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; export function saveDiseaseOutbreak( @@ -13,6 +15,7 @@ export function saveDiseaseOutbreak( diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; + roleRepository: RoleRepository; }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs ): FutureData { @@ -28,26 +31,26 @@ function saveIncidentManagerTeamMemberRole( diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; + roleRepository: RoleRepository; }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs ): FutureData { - return repositories.teamMemberRepository.getAll().flatMap(teamMembers => { + return Future.joinObj({ + roles: repositories.roleRepository.getAll(), + teamMembers: repositories.teamMemberRepository.getAll(), + }).flatMap(({ roles, teamMembers }) => { return repositories.incidentManagementTeamRepository - .get(diseaseOutbreakEventBaseAttrs.id, teamMembers) + .get(diseaseOutbreakEventBaseAttrs.id, teamMembers, roles) .flatMap(incidentManagementTeam => { const incidentManagerTeamMemberFound = incidentManagementTeam?.teamHierarchy?.find( teamMember => teamMember.teamRoles?.some( - teamRole => - teamRole.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRole => teamRole.roleId === INCIDENT_MANAGER_ROLE ) ); const incidentManagerTeamRole = incidentManagerTeamMemberFound?.teamRoles?.find( - teamRole => - teamRole.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRole => teamRole.roleId === INCIDENT_MANAGER_ROLE ); if ( @@ -61,13 +64,15 @@ function saveIncidentManagerTeamMemberRole( diseaseOutbreakEventBaseAttrs, incidentManagerTeamMemberFound, incidentManagerTeamRole, - teamMembers + teamMembers, + roles ); } else { return createNewIncidentManager( repositories, diseaseOutbreakEventBaseAttrs, - teamMembers + teamMembers, + roles ); } }); @@ -83,12 +88,14 @@ function changeIncidentManager( diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, oldIncidentManager: TeamMember, oldIncidentManagerTeamRole: TeamRole, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData { if (oldIncidentManager.username !== diseaseOutbreakEventBaseAttrs.incidentManagerName) { const newIncidentManager = teamMembers.find( teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName ); + if (!newIncidentManager) { return Future.error( new Error( @@ -96,24 +103,28 @@ function changeIncidentManager( ) ); } + const newIncidentManagerTeamRole: TeamRole = { id: "", name: "", - roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + roleId: INCIDENT_MANAGER_ROLE, reportsToUsername: undefined, }; + return repositories.incidentManagementTeamRepository .deleteIncidentManagementTeamMemberRole( oldIncidentManagerTeamRole, oldIncidentManager, - diseaseOutbreakEventBaseAttrs.id + diseaseOutbreakEventBaseAttrs.id, + roles ) .flatMap(() => { return repositories.incidentManagementTeamRepository .saveIncidentManagementTeamMemberRole( newIncidentManagerTeamRole, newIncidentManager, - diseaseOutbreakEventBaseAttrs.id + diseaseOutbreakEventBaseAttrs.id, + roles ) .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); }); @@ -129,7 +140,8 @@ function createNewIncidentManager( teamMemberRepository: TeamMemberRepository; }, diseaseOutbreakEventBaseAttrs: DiseaseOutbreakEventBaseAttrs, - teamMembers: TeamMember[] + teamMembers: TeamMember[], + roles: Role[] ): FutureData { const newIncidentManager = teamMembers.find( teamMember => teamMember.username === diseaseOutbreakEventBaseAttrs.incidentManagerName @@ -146,14 +158,15 @@ function createNewIncidentManager( const incidentManagerTeamRole: TeamRole = { id: "", name: "", - roleId: RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + roleId: INCIDENT_MANAGER_ROLE, reportsToUsername: undefined, }; return repositories.incidentManagementTeamRepository .saveIncidentManagementTeamMemberRole( incidentManagerTeamRole, newIncidentManager, - diseaseOutbreakEventBaseAttrs.id + diseaseOutbreakEventBaseAttrs.id, + roles ) .flatMap(() => Future.success(diseaseOutbreakEventBaseAttrs.id)); } diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts index be105f1f..508f6f2f 100644 --- a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamById.ts @@ -1,16 +1,28 @@ import { FutureData } from "../../../../data/api-futures"; import { Maybe } from "../../../../utils/ts-utils"; +import { Future } from "../../../entities/generic/Future"; import { IncidentManagementTeam } from "../../../entities/incident-management-team/IncidentManagementTeam"; import { Id } from "../../../entities/Ref"; import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; +import { RoleRepository } from "../../../repositories/RoleRepository"; import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; export function getIncidentManagementTeamById( diseaseOutbreakId: Id, - incidentManagementTeamRepository: IncidentManagementTeamRepository, - teamMemberRepository: TeamMemberRepository + repositories: { + roleRepository: RoleRepository; + teamMemberRepository: TeamMemberRepository; + incidentManagementTeamRepository: IncidentManagementTeamRepository; + } ): FutureData> { - return teamMemberRepository.getAll().flatMap(teamMembers => { - return incidentManagementTeamRepository.get(diseaseOutbreakId, teamMembers); + return Future.joinObj({ + roles: repositories.roleRepository.getAll(), + teamMembers: repositories.teamMemberRepository.getAll(), + }).flatMap(({ roles, teamMembers }) => { + return repositories.incidentManagementTeamRepository.get( + diseaseOutbreakId, + teamMembers, + roles + ); }); } diff --git a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts index 4b39c6d4..19e213bf 100644 --- a/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts +++ b/src/domain/usecases/utils/incident-management-team/GetIncidentManagementTeamWithOptions.ts @@ -1,5 +1,5 @@ import { FutureData } from "../../../../data/api-futures"; -import { incidentManagementTeamBuilderCodes } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { Maybe } from "../../../../utils/ts-utils"; import { SECTION_IDS } from "../../../../webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { IncidentManagementTeamMemberFormData } from "../../../entities/ConfigurableForm"; @@ -24,11 +24,7 @@ export function getIncidentManagementTeamWithOptions( roles: repositories.roleRepository.getAll(), teamMembers: repositories.teamMemberRepository.getForIncidentManagementTeamMembers(), incidentManagers: repositories.teamMemberRepository.getIncidentManagers(), - incidentManagementTeam: getIncidentManagementTeamById( - eventTrackerDetails.id, - repositories.incidentManagementTeamRepository, - repositories.teamMemberRepository - ), + incidentManagementTeam: getIncidentManagementTeamById(eventTrackerDetails.id, repositories), }).flatMap(({ roles, teamMembers, incidentManagers, incidentManagementTeam }) => { const teamMemberSelected = incidentManagementTeam?.teamHierarchy.find(teamMember => teamMember.teamRoles?.some(teamRole => teamRole.id === incidentManagementTeamRoleId) @@ -55,8 +51,10 @@ export function getIncidentManagementTeamWithOptions( rules: [ { type: "disableFieldOptionWithSameFieldValue", - fieldId: incidentManagementTeamBuilderCodes.teamMemberAssigned, - fieldIdsToDisableOption: [incidentManagementTeamBuilderCodes.reportsToUsername], + fieldId: incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned, + fieldIdsToDisableOption: [ + incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername, + ], sectionsWithFieldsToDisableOption: [SECTION_IDS.reportsTo], }, ], diff --git a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx index fad574f6..b15fcfc2 100644 --- a/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx +++ b/src/webapp/components/im-team-hierarchy/IMTeamHierarchyView.tsx @@ -39,25 +39,27 @@ export const IMTeamHierarchyView: React.FC = React.mem return ( - - } - defaultExpandIcon={} - > - {items.map(item => ( - - ))} - + + + } + defaultExpandIcon={} + > + {items.map(item => ( + + ))} + + ); }); @@ -80,3 +82,9 @@ const StyledIMTeamHierarchyView = styled(TreeViewMUI)` align-items: baseline; } `; + +const ContentWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; diff --git a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts index 96b5643a..5fc25c0b 100644 --- a/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts +++ b/src/webapp/pages/form-page/incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState.ts @@ -4,15 +4,15 @@ import { mapTeamMemberToUser, mapToPresentationOptions } from "../mapEntityToFor import { Option as UIOption } from "../../../components/utils/option"; import { User } from "../../../components/user-selector/UserSelector"; import { - incidentManagementTeamBuilderCodes, - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS, + INCIDENT_MANAGER_ROLE, + incidentManagementTeamBuilderCodesWithoutRoles, } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; export const TEAM_ROLE_FIELD_ID = "team-role-field"; export const SECTION_IDS = { teamRole: "team-role-section", - teamMemberAssigned: `${incidentManagementTeamBuilderCodes.teamMemberAssigned}-section`, - reportsTo: `${incidentManagementTeamBuilderCodes.reportsToUsername}-section`, + teamMemberAssigned: `${incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned}-section`, + reportsTo: `${incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername}-section`, }; export function mapIncidentManagementTeamMemberToInitialFormState( @@ -30,10 +30,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( const roleOptions: UIOption[] = mapToPresentationOptions(roles); const roleOptionsWithoutIncidentManager: UIOption[] = mapToPresentationOptions( - roles.filter( - role => - role.id !== RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole - ) + roles.filter(role => role.id !== INCIDENT_MANAGER_ROLE) ); const teamMemberOptions: User[] = teamMembers.map(tm => mapTeamMemberToUser(tm)); const incidentManagerOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); @@ -65,16 +62,13 @@ export function mapIncidentManagementTeamMemberToInitialFormState( type: "select", multiple: false, options: - teamRoleToAssing?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE ? roleOptions : roleOptionsWithoutIncidentManager, value: teamRoleToAssing?.roleId || "", required: true, showIsRequired: true, - disabled: - teamRoleToAssing?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole, + disabled: teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE, }, ], }, @@ -85,7 +79,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( required: true, fields: [ { - id: incidentManagementTeamBuilderCodes.teamMemberAssigned, + id: incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned, placeholder: "Select a team member", helperText: "Only available team members are shown", isVisible: true, @@ -93,8 +87,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( type: "select", multiple: false, options: - teamRoleToAssing?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole + teamRoleToAssing?.roleId === INCIDENT_MANAGER_ROLE ? incidentManagerOptions : teamMemberOptions, value: incidentManagementTeamMember?.username || "", @@ -111,7 +104,7 @@ export function mapIncidentManagementTeamMemberToInitialFormState( required: false, fields: [ { - id: incidentManagementTeamBuilderCodes.reportsToUsername, + id: incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername, placeholder: "Select a team member", isVisible: true, errors: [], diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index 46742ebe..0b04834a 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -37,7 +37,7 @@ import { } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; -import { incidentManagementTeamBuilderCodes } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; export function mapFormStateToEntityData( formState: FormState, @@ -497,12 +497,15 @@ function mapFormStateToIncidentManagementTeamMember( const teamMemberAssigned = teamMembers.find(teamMember => { return ( teamMember.username === - getStringFieldValueById(incidentManagementTeamBuilderCodes.teamMemberAssigned) + getStringFieldValueById( + incidentManagementTeamBuilderCodesWithoutRoles.teamMemberAssigned + ) ); }); const reportsToUserNameSelected = - getStringFieldValueById(incidentManagementTeamBuilderCodes.reportsToUsername) || ""; + getStringFieldValueById(incidentManagementTeamBuilderCodesWithoutRoles.reportsToUsername) || + ""; const filteredTeamMemberAssignedRoles = teamMemberAssigned?.teamRoles?.filter( teamRole => teamRole.id !== incidentManagementTeamRoleId diff --git a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts index 0ae8f224..16b84d1f 100644 --- a/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts +++ b/src/webapp/pages/incident-management-team-builder/useIMTeamBuilder.ts @@ -10,7 +10,7 @@ import { IMTeamHierarchyOption } from "../../components/im-team-hierarchy/IMTeam import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { IncidentManagementTeam } from "../../../domain/entities/incident-management-team/IncidentManagementTeam"; import { TeamMember, TeamRole } from "../../../domain/entities/incident-management-team/TeamMember"; -import { RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { INCIDENT_MANAGER_ROLE } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import _c from "../../../domain/entities/generic/Collection"; type GlobalMessage = { @@ -106,9 +106,7 @@ export function useIMTeamBuilder(id: Id): State { role => role.id === selection ); - const isIncidentManagerRoleSelected = - selectedRole?.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole; + const isIncidentManagerRoleSelected = selectedRole?.roleId === INCIDENT_MANAGER_ROLE; setSelectedHierarchyItemId(selection); setDisableDeletion(isIncidentManagerRoleSelected); @@ -191,11 +189,7 @@ export function useIMTeamBuilder(id: Id): State { const incidentManagerUser = useMemo(() => { const incidentManagerTeamMember = incidentManagementTeam?.teamHierarchy.find(member => { - return member.teamRoles?.some( - role => - role.roleId === - RTSL_ZEBRA_INCIDENT_MANAGEMENT_TEAM_BUILDER_ROLE_IDS.incidentManagerRole - ); + return member.teamRoles?.some(role => role.roleId === INCIDENT_MANAGER_ROLE); }); if (incidentManagerTeamMember) { return mapTeamMemberToUser(incidentManagerTeamMember); From e1b3a19e4bcd544757d329fbf65df98a4b9fca44 Mon Sep 17 00:00:00 2001 From: 9sneha-n <9sneha.n@gmail.com> Date: Thu, 10 Oct 2024 23:18:19 +0530 Subject: [PATCH 23/24] feat: overview cards --- src/CompositionRoot.ts | 4 + .../PerformanceOverviewD2Repository.ts | 149 ++++++++++++++++++ .../test/PerformanceOverviewTestRepository.ts | 4 + .../utils/RiskAssessmentMapper.ts | 1 - src/domain/entities/PerformanceOverview.ts | 4 + .../PerformanceOverviewRepository.ts | 2 + .../usecases/GetOverviewCardsUseCase.ts | 11 ++ src/webapp/pages/dashboard/DashboardPage.tsx | 2 +- .../pages/event-tracker/EventTrackerPage.tsx | 26 ++- .../pages/event-tracker/useOverviewCards.ts | 34 ++++ 10 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 src/domain/entities/PerformanceOverview.ts create mode 100644 src/domain/usecases/GetOverviewCardsUseCase.ts create mode 100644 src/webapp/pages/event-tracker/useOverviewCards.ts diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 422f8652..5f4a4246 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -50,6 +50,7 @@ import { GetAnalyticsRuntimeUseCase } from "./domain/usecases/GetAnalyticsRuntim import { SystemRepository } from "./domain/repositories/SystemRepository"; import { SystemD2Repository } from "./data/repositories/SystemD2Repository"; import { SystemTestRepository } from "./data/repositories/test/SystemTestRepository"; +import { GetOverviewCardsUseCase } from "./domain/usecases/GetOverviewCardsUseCase"; export type CompositionRoot = ReturnType; @@ -94,6 +95,9 @@ function getCompositionRoot(repositories: Repositories) { getTotalCardCounts: new GetTotalCardCountsUseCase(repositories), get717Performance: new Get717PerformanceUseCase(repositories), getAnalyticsRuntime: new GetAnalyticsRuntimeUseCase(repositories), + getOverviewCards: new GetOverviewCardsUseCase( + repositories.performanceOverviewRepository + ), }, maps: { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index 1d544278..f2b82472 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -27,6 +27,8 @@ import { import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; import { OrgUnit } from "../../domain/entities/OrgUnit"; import { Id } from "../../domain/entities/Ref"; +import { OverviewCard } from "../../domain/entities/PerformanceOverview"; +import { assertOrError } from "./utils/AssertOrError"; const formatDate = (date: Date): string => { const year = date.getFullYear(); @@ -37,6 +39,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 = { + key: string; + suspectedCasesId: Id; + confirmedCasesId: Id; + deathsId: Id; + probableCasesId: Id; +}; export class PerformanceOverviewD2Repository implements PerformanceOverviewRepository { constructor(private api: D2Api, private datastore: DataStoreClient) {} @@ -89,6 +100,7 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos return Object.values(uniqueTotalCardCounts); }); } + mapAnalyticsRowsToTotalCardCounts = ( rowData: string[][], filters?: Record @@ -142,6 +154,143 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos return filteredCounts; }; + private getAnalyticsApi(caseId: string, startDate: string) { + return apiToFuture( + this.api.analytics.get({ + dimension: [`dx:${caseId}`], + startDate: startDate, + endDate: DEFAULT_END_DATE, + }) + ); + } + + private getEventTrackerOverviewIdsFromDatastore( + type: string + ): FutureData { + return this.datastore + .getObject(EVENT_TRACKER_OVERVIEW_DATASTORE_KEY) + .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 Tracke Overview Ids for type ${type} not found in datastore` + ) + ); + return Future.success(currentEventTrackerOverviewId); + }); + }); + } + + 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, + }, + ]); + } + ); + }); + } + getPerformanceOverviewMetrics( diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] ): FutureData { diff --git a/src/data/repositories/test/PerformanceOverviewTestRepository.ts b/src/data/repositories/test/PerformanceOverviewTestRepository.ts index c56a2a95..311d911c 100644 --- a/src/data/repositories/test/PerformanceOverviewTestRepository.ts +++ b/src/data/repositories/test/PerformanceOverviewTestRepository.ts @@ -1,5 +1,6 @@ import { PerformanceMetrics717 } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { Future } from "../../../domain/entities/generic/Future"; +import { OverviewCard } from "../../../domain/entities/PerformanceOverview"; import { Id } from "../../../domain/entities/Ref"; import { PerformanceOverviewRepository } from "../../../domain/repositories/PerformanceOverviewRepository"; import { FutureData } from "../../api-futures"; @@ -10,6 +11,9 @@ export class PerformanceOverviewTestRepository implements PerformanceOverviewRep ): FutureData { return Future.success([]); } + getEventTrackerOverviewMetrics(): FutureData { + throw Future.success([]); + } getTotalCardCounts(): FutureData { return Future.success(0); } diff --git a/src/data/repositories/utils/RiskAssessmentMapper.ts b/src/data/repositories/utils/RiskAssessmentMapper.ts index a719f362..e22e3b13 100644 --- a/src/data/repositories/utils/RiskAssessmentMapper.ts +++ b/src/data/repositories/utils/RiskAssessmentMapper.ts @@ -43,7 +43,6 @@ import { RiskAssessmentSummaryFormData, } from "../../../domain/entities/ConfigurableForm"; import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; -import { getDateAsLocaleDateString } from "./DateTimeHelper"; type D2ProgramStageDataElementsMetadata = { dataElement: SelectedPick< diff --git a/src/domain/entities/PerformanceOverview.ts b/src/domain/entities/PerformanceOverview.ts new file mode 100644 index 00000000..1ae41701 --- /dev/null +++ b/src/domain/entities/PerformanceOverview.ts @@ -0,0 +1,4 @@ +export type OverviewCard = { + name: string; + value: number; +}; diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index 1f37a4ba..9fba312a 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -5,6 +5,7 @@ import { PerformanceOverviewMetrics, PerformanceMetrics717, } from "../entities/disease-outbreak-event/PerformanceOverviewMetrics"; +import { OverviewCard } from "../entities/PerformanceOverview"; import { Id } from "../entities/Ref"; export interface PerformanceOverviewRepository { @@ -19,4 +20,5 @@ export interface PerformanceOverviewRepository { ): FutureData; getDashboard717Performance(): FutureData; getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData; + getEventTrackerOverviewMetrics(type: string): FutureData; } diff --git a/src/domain/usecases/GetOverviewCardsUseCase.ts b/src/domain/usecases/GetOverviewCardsUseCase.ts new file mode 100644 index 00000000..2fdfa6ce --- /dev/null +++ b/src/domain/usecases/GetOverviewCardsUseCase.ts @@ -0,0 +1,11 @@ +import { FutureData } from "../../data/api-futures"; +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); + } +} diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 600012b7..76812a63 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -163,7 +163,7 @@ export const GridWrapper = styled.div` gap: 10px; `; -const StyledStatsCard = styled(StatsCard)` +export const StyledStatsCard = styled(StatsCard)` width: 220px; `; diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index b171a487..f77d2c6b 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -19,9 +19,10 @@ import { useMapFilters } from "./useMapFilters"; import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; import { NoticeBox } from "../../components/notice-box/NoticeBox"; import { PerformanceMetric717, use717Performance } from "../dashboard/use717Performance"; -import { GridWrapper } from "../dashboard/DashboardPage"; +import { GridWrapper, StyledStatsCard } from "../dashboard/DashboardPage"; import { StatsCard } from "../../components/stats-card/StatsCard"; import { useLastAnalyticsRuntime } from "../../hooks/useLastAnalyticsRuntime"; +import { useOverviewCards } from "./useOverviewCards"; //TO DO : Create Risk assessment section export const riskAssessmentColumns: TableColumn[] = [ @@ -49,6 +50,8 @@ export const EventTrackerPage: React.FC = React.memo(() => { const currentEventTracker = getCurrentEventTracker(); const { lastAnalyticsRuntime } = useLastAnalyticsRuntime(); + const { overviewCards, isLoading: areOverviewCardsLoading } = useOverviewCards(); + const { dateRangeFilter } = useMapFilters(); const goToRiskSummaryForm = useCallback(() => { @@ -86,7 +89,8 @@ export const EventTrackerPage: React.FC = React.memo(() => { { /> )}
+
+ + {overviewCards?.map((card, index) => ( + + ))} + +
{ } />
-
+
{performanceMetrics717.map( (perfMetric: PerformanceMetric717, index: number) => ( diff --git a/src/webapp/pages/event-tracker/useOverviewCards.ts b/src/webapp/pages/event-tracker/useOverviewCards.ts new file mode 100644 index 00000000..1ef02b7c --- /dev/null +++ b/src/webapp/pages/event-tracker/useOverviewCards.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { useAppContext } from "../../contexts/app-context"; +import { useCurrentEventTracker } from "../../contexts/current-event-tracker-context"; +import { OverviewCard } from "../../../domain/entities/PerformanceOverview"; + +export function useOverviewCards() { + const { compositionRoot } = useAppContext(); + const [overviewCards, setOverviewCards] = useState(); + const [isLoading, setIsLoading] = useState(false); + const { getCurrentEventTracker } = useCurrentEventTracker(); + const currentEventTracker = getCurrentEventTracker(); + + useEffect(() => { + const type = currentEventTracker?.suspectedDiseaseCode || currentEventTracker?.hazardType; + if (type) { + setIsLoading(true); + compositionRoot.performanceOverview.getOverviewCards.execute(type).run( + overviewCards => { + setIsLoading(false); + setOverviewCards(overviewCards); + }, + err => { + setIsLoading(false); + console.error(err); + } + ); + } + }, [compositionRoot.performanceOverview.getOverviewCards, currentEventTracker]); + + return { + overviewCards, + isLoading, + }; +} From ea0e60907d081799e48ee848545fe12c5a8415a8 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Fri, 11 Oct 2024 09:12:33 +0200 Subject: [PATCH 24/24] Fix Duration filter in dashboards and add contentLoader in respond,alert,watch cards --- .../date-picker/DateRangePicker.tsx | 18 +++++++------ src/webapp/pages/dashboard/DashboardPage.tsx | 25 +++++++++++-------- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/webapp/components/date-picker/DateRangePicker.tsx b/src/webapp/components/date-picker/DateRangePicker.tsx index a28404ba..629d9c13 100644 --- a/src/webapp/components/date-picker/DateRangePicker.tsx +++ b/src/webapp/components/date-picker/DateRangePicker.tsx @@ -21,8 +21,12 @@ const ID = "date-range-picker"; export const DateRangePicker: React.FC = React.memo( ({ label = "", value, placeholder = "", onChange }) => { const [anchorEl, setAnchorEl] = useState(null); - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); + const [startDate, setStartDate] = useState( + value && value[0] ? new Date(value[0]) : null + ); + const [endDate, setEndDate] = useState( + value && value[1] ? new Date(value[1]) : null + ); const handleOpen = useCallback((event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -41,14 +45,14 @@ export const DateRangePicker: React.FC = React.memo( }, [onCleanValues, value.length]); const formatDurationValue = useMemo(() => { - if (!value || value.length !== 2) { + if (!value || value.length !== 2 || !value[0] || !value[1]) { return placeholder; } - return `${moment(startDate).format("DD/MM/yyyy")} — ${moment(endDate).format( - "DD/MM/yyyy" - )}`; - }, [startDate, endDate, placeholder, value]); + return `${moment(new Date(value[0])).format("DD/MM/yyyy")} — ${moment( + new Date(value[1]) + ).format("DD/MM/yyyy")}`; + }, [placeholder, value]); const onReset = useCallback(() => { onChange([]); diff --git a/src/webapp/pages/dashboard/DashboardPage.tsx b/src/webapp/pages/dashboard/DashboardPage.tsx index 76812a63..6dbeadeb 100644 --- a/src/webapp/pages/dashboard/DashboardPage.tsx +++ b/src/webapp/pages/dashboard/DashboardPage.tsx @@ -17,6 +17,7 @@ import { DateRangePicker } from "../../components/date-picker/DateRangePicker"; import { PerformanceMetric717, use717Performance } from "./use717Performance"; import { Loader } from "../../components/loader/Loader"; import { useLastAnalyticsRuntime } from "../../hooks/useLastAnalyticsRuntime"; +import LoaderContainer from "../../components/loader/LoaderContainer"; export const DashboardPage: React.FC = React.memo(() => { const { @@ -54,7 +55,7 @@ export const DashboardPage: React.FC = React.memo(() => { resetCurrentEventTrackerId(); }); - return performanceOverviewLoading || _717CardsLoading || cardCountsLoading ? ( + return performanceOverviewLoading || _717CardsLoading ? ( ) : ( { /> - - {cardCounts.map((cardCount, index) => ( - - ))} - + + + {cardCounts.map((cardCount, index) => ( + + ))} + +