From 0370686ca8bec233caf4f1c9dec796f934abdcb7 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 21 Nov 2024 09:17:45 +0100 Subject: [PATCH 01/21] Improve code, install xlsx and react-dropzone --- i18n/en.pot | 47 +++++++++-- i18n/es.po | 45 ++++++++-- package.json | 2 + src/data/repositories/OrgUnitD2Repository.ts | 27 ++++-- src/data/repositories/utils/MetadataHelper.ts | 36 +++++++- src/domain/entities/OrgUnit.ts | 2 +- .../IMTeamBuilderPage.tsx | 10 +-- yarn.lock | 84 +++++++++++++++++++ 8 files changed, 218 insertions(+), 35 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 836445da..962d2e89 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-11-08T16:32:23.115Z\n" -"PO-Revision-Date: 2024-11-08T16:32:23.115Z\n" +"POT-Creation-Date: 2024-11-21T08:13:13.190Z\n" +"PO-Revision-Date: 2024-11-21T08:13:13.190Z\n" msgid "Low" msgstr "" @@ -75,9 +75,6 @@ msgstr "" msgid "Save" msgstr "" -msgid "There is an error in this field" -msgstr "" - msgid "Indicates required" msgstr "" @@ -102,6 +99,30 @@ msgstr "" msgid "Currently assigned:" msgstr "" +msgid "Multiple uploads not allowed, please select one file" +msgstr "" + +msgid "Error uploading file." +msgstr "" + +msgid "Select a file" +msgstr "" + +msgid "Errors in cases data file" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm remove the file" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Are you sure you want to remove the file?" +msgstr "" + msgid "Error loading current Incident Management Team" msgstr "" @@ -114,9 +135,6 @@ msgstr "" msgid "Map not found." msgstr "" -msgid "Close" -msgstr "" - msgid "Search" msgstr "" @@ -189,6 +207,14 @@ msgstr "" msgid "N/A" msgstr "" +msgid "" +"In order to add or replace cases, you need to download the current file and " +"add the new ones." +msgstr "" + +msgid "Please, download the template and add the required data." +msgstr "" + msgid "Add another" msgstr "" @@ -252,7 +278,10 @@ msgstr "" msgid "Confirm deletion" msgstr "" -msgid "Delete" +msgid "Are you sure you want to delete these team roles?" +msgstr "" + +msgid "Are you sure you want to delete this team role?" msgstr "" msgid "Resources" diff --git a/i18n/es.po b/i18n/es.po index a4a9ac52..48b16e45 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-11-08T16:32:23.115Z\n" +"POT-Creation-Date: 2024-11-21T08:13:13.190Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -74,9 +74,6 @@ msgstr "" msgid "Save" msgstr "" -msgid "There is an error in this field" -msgstr "" - msgid "Indicates required" msgstr "" @@ -101,6 +98,30 @@ msgstr "" msgid "Currently assigned:" msgstr "" +msgid "Multiple uploads not allowed, please select one file" +msgstr "" + +msgid "Error uploading file." +msgstr "" + +msgid "Select a file" +msgstr "" + +msgid "Errors in cases data file" +msgstr "" + +msgid "Close" +msgstr "" + +msgid "Confirm remove the file" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "Are you sure you want to remove the file?" +msgstr "" + msgid "Error loading current Incident Management Team" msgstr "" @@ -113,9 +134,6 @@ msgstr "" msgid "Map not found." msgstr "" -msgid "Close" -msgstr "" - msgid "Search" msgstr "" @@ -188,6 +206,14 @@ msgstr "" msgid "N/A" msgstr "" +msgid "" +"In order to add or replace cases, you need to download the current file and " +"add the new ones." +msgstr "" + +msgid "Please, download the template and add the required data." +msgstr "" + msgid "Add another" msgstr "" @@ -251,7 +277,10 @@ msgstr "" msgid "Confirm deletion" msgstr "" -msgid "Delete" +msgid "Are you sure you want to delete these team roles?" +msgstr "" + +msgid "Are you sure you want to delete this team role?" msgstr "" msgid "Resources" diff --git a/package.json b/package.json index 0854e6b7..a7e24476 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,14 @@ "purify-ts-extra-codec": "0.6.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.3.5", "react-router-dom": "5.2.0", "real-cancellable-promise": "^1.1.2", "string-ts": "2.2.0", "styled-components": "5.3.5", "styled-jsx": "3.4.5", "typed-immutable-map": "^0.1.1", + "xlsx": "^0.18.5", "zustand": "^4.3.7" }, "devDependencies": { diff --git a/src/data/repositories/OrgUnitD2Repository.ts b/src/data/repositories/OrgUnitD2Repository.ts index bb325f02..9e662355 100644 --- a/src/data/repositories/OrgUnitD2Repository.ts +++ b/src/data/repositories/OrgUnitD2Repository.ts @@ -1,9 +1,14 @@ import { D2Api, MetadataPick } from "../../types/d2-api"; -import { OrgUnit } from "../../domain/entities/OrgUnit"; +import { OrgUnit, OrgUnitLevelType } from "../../domain/entities/OrgUnit"; import { Id } from "../../domain/entities/Ref"; import { OrgUnitRepository } from "../../domain/repositories/OrgUnitRepository"; import { apiToFuture, FutureData } from "../api-futures"; +const orgUnitLevelTypeByLevelNumber: Record = { + 1: "National", + 2: "Province", + 3: "District", +}; export class OrgUnitD2Repository implements OrgUnitRepository { constructor(private api: D2Api) {} @@ -12,7 +17,7 @@ export class OrgUnitD2Repository implements OrgUnitRepository { this.api.metadata.get({ organisationUnits: { fields: d2OrgUnitFields, - filter: { level: { in: ["2", "3"] } }, + filter: { level: { in: ["1", "2", "3"] } }, }, }) ).map(response => { @@ -46,14 +51,18 @@ export class OrgUnitD2Repository implements OrgUnitRepository { } private mapD2OrgUnitsToOrgUnits(d2OrgUnit: D2OrgUnit[]): OrgUnit[] { - return d2OrgUnit.map( - (ou): OrgUnit => ({ - id: ou.id, - name: ou.name, - code: ou.code, - level: ou.level === 2 ? "Province" : "District", + return d2OrgUnit + .map(ou => { + if (orgUnitLevelTypeByLevelNumber[ou.level]) { + return { + id: ou.id, + name: ou.name, + code: ou.code, + level: orgUnitLevelTypeByLevelNumber[ou.level], + }; + } }) - ); + .filter((orgUnit): orgUnit is OrgUnit => orgUnit !== undefined); } } diff --git a/src/data/repositories/utils/MetadataHelper.ts b/src/data/repositories/utils/MetadataHelper.ts index 7ea8ddb0..5248db2d 100644 --- a/src/data/repositories/utils/MetadataHelper.ts +++ b/src/data/repositories/utils/MetadataHelper.ts @@ -1,6 +1,6 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { Id, Ref } from "../../../domain/entities/Ref"; -import { D2Api } from "../../../types/d2-api"; +import { D2Api, MetadataPick } from "../../../types/d2-api"; import { apiToFuture, FutureData } from "../../api-futures"; import { assertOrError } from "./AssertOrError"; import { Attribute } from "@eyeseetea/d2-api/api/trackedEntityInstances"; @@ -73,3 +73,37 @@ export function getProgramStage(api: D2Api, stageId: Id) { }) ); } + +export function getProgramDataElementsMetadata(api: D2Api, programId: Id) { + return apiToFuture( + api.models.programs.get({ + fields: programDataElementsFields, + filter: { + id: { eq: programId }, + }, + }) + ); +} + +const programDataElementsFields = { + id: true, + programStages: { + id: true, + name: true, + programStageDataElements: { + dataElement: { + id: true, + code: true, + valueType: true, + optionSetValue: true, + optionSet: { options: { name: true, code: true } }, + }, + }, + }, +} as const; + +export type D2ProgramStageDataElement = MetadataPick<{ + programStageDataElements: { + fields: typeof programDataElementsFields.programStages.programStageDataElements; + }; +}>["programStageDataElements"][number]; diff --git a/src/domain/entities/OrgUnit.ts b/src/domain/entities/OrgUnit.ts index 554c0721..0655de7a 100644 --- a/src/domain/entities/OrgUnit.ts +++ b/src/domain/entities/OrgUnit.ts @@ -1,6 +1,6 @@ import { CodedNamedRef } from "./Ref"; -type OrgUnitLevelType = "Province" | "District"; +export type OrgUnitLevelType = "National" | "Province" | "District"; export type OrgUnit = CodedNamedRef & { level: OrgUnitLevelType; diff --git a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx index 4d5db70a..7f02b844 100644 --- a/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx +++ b/src/webapp/pages/incident-management-team-builder/IMTeamBuilderPage.tsx @@ -130,13 +130,9 @@ export const IMTeamBuilderPage: React.FC = React.memo(() => { > {openDeleteModalData && ( - {i18n.t( - `Are you sure you want to delete ${ - openDeleteModalData.length > 1 - ? "these team roles" - : "this team role" - }?` - )} + {openDeleteModalData.length > 1 + ? i18n.t(`Are you sure you want to delete these team roles?`) + : i18n.t(`Are you sure you want to delete this team role?`)} )} diff --git a/yarn.lock b/yarn.lock index 462c41de..11d8375b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4611,6 +4611,11 @@ acorn@^8.1.0, acorn@^8.10.0, acorn@^8.11.0, acorn@^8.11.3, acorn@^8.4.1, acorn@^ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.1.tgz#71616bdccbe25e27a54439e0046e89ca76df2248" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== +adler-32@~1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/adler-32/-/adler-32-1.3.1.tgz#1dbf0b36dda0012189a32b3679061932df1821e2" + integrity sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A== + agent-base@6: version "6.0.2" resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -4881,6 +4886,11 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== +attr-accept@^2.2.4: + version "2.2.5" + resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-2.2.5.tgz#d7061d958e6d4f97bf8665c68b75851a0713ab5e" + integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== + author-regex@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/author-regex/-/author-regex-0.2.1.tgz#8bdefaac6065a931799bec07eeef51b940e08f3c" @@ -5302,6 +5312,14 @@ caniuse-lite@^1.0.30001640: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001644.tgz#bcd4212a7a03bdedba1ea850b8a72bfe4bec2395" integrity sha512-YGvlOZB4QhZuiis+ETS0VXR+MExbFf4fZYYeMTEE0aTQd/RdIjkTyZjLrbYVKnHzppDvnOhritRVv+i7Go6mHw== +cfb@~1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cfb/-/cfb-1.2.2.tgz#94e687628c700e5155436dac05f74e08df23bc44" + integrity sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA== + dependencies: + adler-32 "~1.3.0" + crc-32 "~1.2.0" + chai@^4.3.7: version "4.5.0" resolved "https://registry.yarnpkg.com/chai/-/chai-4.5.0.tgz#707e49923afdd9b13a8b0b47d33d732d13812fd8" @@ -5548,6 +5566,11 @@ code-block-writer@^11.0.0: resolved "https://registry.yarnpkg.com/code-block-writer/-/code-block-writer-11.0.3.tgz#9eec2993edfb79bfae845fbc093758c0a0b73b76" integrity sha512-NiujjUFB4SwScJq2bwbYUtXbZhBSlY6vYzm++3Q6oC+U+injTqfPYFK8wS9COOmb2lueqp0ZRB4nK1VYeHgNyw== +codepage@~1.15.0: + version "1.15.0" + resolved "https://registry.yarnpkg.com/codepage/-/codepage-1.15.0.tgz#2e00519024b39424ec66eeb3ec07227e692618ab" + integrity sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -5718,6 +5741,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: path-type "^4.0.0" yaml "^1.10.0" +crc-32@~1.2.0, crc-32@~1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/crc-32/-/crc-32-1.2.2.tgz#3cad35a934b8bf71f25ca524b6da51fb7eace2ff" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + create-ecdh@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e" @@ -6939,6 +6967,13 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" +file-selector@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-2.1.0.tgz#beb164ca5ce48af8a48d3e632c94750bc573581a" + integrity sha512-ZuXAqGePcSPz4JuerOY06Dzzq0hrmQ6VGoXVzGyFI1npeOfBgqGIKKpznfYWRkSLJlXutkqVC5WvGZtkFVhu9Q== + dependencies: + tslib "^2.7.0" + filelist@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" @@ -7074,6 +7109,11 @@ forwarded@0.2.0: resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== +frac@~1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/frac/-/frac-1.1.2.tgz#3d74f7f6478c88a1b5020306d747dc6313c74d0b" + integrity sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA== + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -9615,6 +9655,15 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-dropzone@^14.3.5: + version "14.3.5" + resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-14.3.5.tgz#1a8bd312c8a353ec78ef402842ccb3589c225add" + integrity sha512-9nDUaEEpqZLOz5v5SUcFA0CjM4vq8YbqO0WRls+EYT7+DvxUdzDPKNCPLqGfj3YL9MsniCLCD4RFA6M95V6KMQ== + dependencies: + attr-accept "^2.2.4" + file-selector "^2.1.0" + prop-types "^15.8.1" + react-event-listener@^0.6.2: version "0.6.6" resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.6.6.tgz#758f7b991cad9086dd39fd29fad72127e1d8962a" @@ -10430,6 +10479,13 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +ssf@~0.11.2: + version "0.11.2" + resolved "https://registry.yarnpkg.com/ssf/-/ssf-0.11.2.tgz#0b99698b237548d088fc43cdf2b70c1a7512c06c" + integrity sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g== + dependencies: + frac "~1.1.2" + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -10971,6 +11027,11 @@ tslib@^2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== +tslib@^2.7.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -11689,11 +11750,21 @@ widest-line@^3.1.0: dependencies: string-width "^4.0.0" +wmf@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wmf/-/wmf-1.0.2.tgz#7d19d621071a08c2bdc6b7e688a9c435298cc2da" + integrity sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw== + word-wrap@^1.2.5: version "1.2.5" resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +word@~0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/word/-/word-0.3.0.tgz#8542157e4f8e849f4a363a288992d47612db9961" + integrity sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA== + wordwrap@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" @@ -11751,6 +11822,19 @@ xdg-basedir@^4.0.0: resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== +xlsx@^0.18.5: + version "0.18.5" + resolved "https://registry.yarnpkg.com/xlsx/-/xlsx-0.18.5.tgz#16711b9113c848076b8a177022799ad356eba7d0" + integrity sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ== + dependencies: + adler-32 "~1.3.0" + cfb "~1.2.1" + codepage "~1.15.0" + crc-32 "~1.2.1" + ssf "~0.11.2" + wmf "~1.0.1" + word "~0.3.0" + xml-name-validator@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-4.0.0.tgz#79a006e2e63149a8600f15430f0a4725d1524835" From 95abe75b16303055846a91cd466f16b00a7da7da Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 21 Nov 2024 09:18:32 +0100 Subject: [PATCH 02/21] Add Dropzone and ImportFile components --- .../components/import-file/Dropzone.tsx | 36 +++ .../components/import-file/ImportFile.tsx | 221 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 src/webapp/components/import-file/Dropzone.tsx create mode 100644 src/webapp/components/import-file/ImportFile.tsx diff --git a/src/webapp/components/import-file/Dropzone.tsx b/src/webapp/components/import-file/Dropzone.tsx new file mode 100644 index 00000000..7b5019b0 --- /dev/null +++ b/src/webapp/components/import-file/Dropzone.tsx @@ -0,0 +1,36 @@ +import React, { useImperativeHandle, useRef } from "react"; +import { DropzoneOptions, useDropzone } from "react-dropzone"; + +type DropzoneProps = DropzoneOptions & { + children?: React.ReactNode; + visible?: boolean; +}; + +export type CustomDropzoneRef = { + openDialog: () => void; +}; + +export const Dropzone = React.forwardRef( + (props: DropzoneProps, ref: React.ForwardedRef) => { + const childrenRef = useRef(null); + const { getRootProps, getInputProps, open } = useDropzone({ + noClick: !props.visible, + ...props, + }); + + useImperativeHandle(ref, () => ({ + openDialog() { + open(); + }, + })); + + return ( +
+ +
+ {props.children} +
+
+ ); + } +); diff --git a/src/webapp/components/import-file/ImportFile.tsx b/src/webapp/components/import-file/ImportFile.tsx new file mode 100644 index 00000000..91ad025f --- /dev/null +++ b/src/webapp/components/import-file/ImportFile.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useRef, useState } from "react"; +import styled from "styled-components"; +import i18n from "../../../utils/i18n"; +import { CustomDropzoneRef, Dropzone } from "./Dropzone"; +import BackupIcon from "@material-ui/icons/Backup"; +import CloseIcon from "@material-ui/icons/Close"; +import WarningIcon from "@material-ui/icons/Warning"; +import { Button } from "../button/Button"; +import { FileRejection } from "react-dropzone/."; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import { FormHelperText, InputLabel, Link } from "@mui/material"; +import { Maybe } from "../../../utils/ts-utils"; +import { IconButton } from "../icon-button/IconButton"; +import { SimpleModal } from "../simple-modal/SimpleModal"; +import { readFile } from "../../pages/form-page/utils/FileHelper"; +import { SheetData } from "../form/FormFieldsState"; + +type ImportFileProps = { + id: string; + fileTemplate: Maybe; + file: Maybe; + onChange: (file: File | undefined, sheetData: SheetData | undefined) => void; + label?: string; + placeholder?: string; + disabled?: boolean; + helperText?: string; + errorText?: string; + error?: boolean; + required?: boolean; +}; + +export const ImportFile: React.FC = React.memo(props => { + const { file, onChange, label, id, helperText, errorText, error, required, fileTemplate } = + props; + const snackbar = useSnackbar(); + const dropzoneRef = useRef(null); + const [openDeleteModal, setOpenDeleteModal] = useState(false); + const [openErrorsModal, setOpenErrorsModal] = useState(false); + + const openFileUploadDialog = useCallback(() => { + dropzoneRef.current?.openDialog(); + }, [dropzoneRef]); + + const onDropFile = useCallback( + (files: File[], rejections: FileRejection[]) => { + const handleFileUpload = async () => { + const uploadedFile = files[0]; + if (rejections.length > 0 || !uploadedFile) { + snackbar.error(i18n.t("Multiple uploads not allowed, please select one file")); + } else { + const spreadsheets = await readFile(uploadedFile); + const spreadsheet = spreadsheets[0]; // only one sheet + const headerRow = spreadsheet?.headers; + const rows = spreadsheet?.rows; + onChange(uploadedFile, { + headers: headerRow || [], + rows: rows || [], + }); + } + }; + + handleFileUpload().catch(error => { + snackbar.error(i18n.t("Error uploading file.")); + console.error("Error uploading file:", error); + }); + }, + [onChange, snackbar] + ); + + const onOpenConfirmationModalRemoveFile = useCallback(() => { + setOpenDeleteModal(true); + }, []); + + const onOpenErrorsModal = useCallback(() => { + setOpenErrorsModal(true); + }, []); + + const onConfirmRemoveFile = useCallback(() => { + onChange(undefined, undefined); + setOpenDeleteModal(false); + }, [onChange]); + + return ( + + {label && ( + + )} + + + + + + {error && !!errorText && ( + <> + } + ariaLabel="Show error messages" + onClick={onOpenErrorsModal} + /> + setOpenErrorsModal(false)} + title={i18n.t("Errors in cases data file")} + closeLabel={i18n.t("Close")} + > + {openErrorsModal && {errorText}} + + + )} + + {fileTemplate && ( + + {fileTemplate.name} + + )} + + {file && ( + + } + ariaLabel="Delete current uploaded file" + onClick={onOpenConfirmationModalRemoveFile} + /> + + {file.name} + + setOpenDeleteModal(false)} + title={i18n.t("Confirm remove the file")} + closeLabel={i18n.t("Cancel")} + footerButtons={ + + } + > + {openDeleteModal && ( + {i18n.t(`Are you sure you want to remove the file?`)} + )} + + + )} + {helperText && ( + {helperText} + )} + + ); +}); + +const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +const FlexContainer = styled.div<{ gap?: number }>` + display: flex; + align-items: center; + gap: ${props => (props.gap ? props.gap : 24)}px; + .errors-file { + color: ${props => props.theme.palette.common.red700}; + } +`; + +const Label = styled(InputLabel)` + display: inline-block; + font-weight: 700; + font-size: 0.875rem; + color: ${props => props.theme.palette.text.primary}; + margin-block-end: 8px; + + &.required::after { + content: "*"; + color: ${props => props.theme.palette.common.red}; + margin-inline-start: 4px; + } +`; + +const StyledFormHelperText = styled(FormHelperText)<{ error?: boolean }>` + color: ${props => + props.error ? props.theme.palette.common.red700 : props.theme.palette.common.grey700}; +`; + +const RemoveContainer = styled.div` + display: flex; + align-items: center; + margin-block-start: 5px; + .remove-file { + color: ${props => props.theme.palette.common.red700}; + } +`; + +const Text = styled.div` + font-weight: 400; + font-size: 0.875rem; + color: ${props => props.theme.palette.common.grey900}; +`; + +const ErrorsText = styled.div` + font-weight: 400; + font-size: 0.875rem; + white-space: pre-wrap; + color: ${props => props.theme.palette.common.red700}; +`; + +ImportFile.displayName = "ImportFile"; From f329a48dd1afbaf760eb2cb3b2b591c8bb9ae341 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 21 Nov 2024 09:27:09 +0100 Subject: [PATCH 03/21] Allow to add and edit case data file in disease outbreak event --- src/CompositionRoot.ts | 11 +- .../AlertSyncDataStoreRepository.ts | 29 +-- .../repositories/CasesFileD2Repository.ts | 140 +++++++++++++ .../ConfigurationsD2Repository.ts | 9 + .../DiseaseOutbreakEventD2Repository.ts | 157 ++++++++++++++- src/data/repositories/UserD2Repository.ts | 3 +- .../repositories/consts/CaseDataConstants.ts | 57 ++++++ .../consts/DiseaseOutbreakConstants.ts | 10 +- .../test/CasesFileTestRepository.ts | 29 +++ .../test/ConfigurationsTestRepository.ts | 1 + .../DiseaseOutbreakEventTestRepository.ts | 4 + .../repositories/utils/AlertOutbreakMapper.ts | 24 --- .../utils/DiseaseOutbreakMapper.ts | 8 +- .../entities/AlertsAndCaseForCasesData.ts | 49 +++++ src/domain/entities/AppConfigurations.ts | 2 + src/domain/entities/AppDatastoreConfig.ts | 13 ++ src/domain/entities/CasesFile.ts | 30 +++ src/domain/entities/ConfigurableForm.ts | 14 +- src/domain/entities/User.ts | 10 +- src/domain/entities/ValidationError.ts | 12 +- src/domain/entities/alert/AlertData.ts | 19 -- .../DiseaseOutbreakEvent.ts | 27 ++- .../incident-management-team/TeamMember.ts | 3 +- .../repositories/CasesFileRepository.ts | 10 + .../DiseaseOutbreakEventRepository.ts | 7 +- src/domain/usecases/GetAllOrgUnitsUseCase.ts | 11 - .../usecases/GetConfigurableFormUseCase.ts | 2 + .../usecases/GetConfigurationsUseCase.ts | 7 +- .../usecases/GetDiseaseOutbreakByIdUseCase.ts | 1 + src/domain/usecases/SaveEntityUseCase.ts | 72 +++++-- .../GetDiseaseOutbreakConfigurableForm.ts | 109 ++++++++-- .../disease-outbreak/SaveDiseaseOutbreak.ts | 73 ++++++- src/scripts/mapDiseaseOutbreakToAlerts.ts | 6 +- src/utils/tests.tsx | 3 +- src/webapp/components/form/FieldWidget.tsx | 25 ++- src/webapp/components/form/FormFieldsState.ts | 75 ++++++- src/webapp/components/map/MapSection.tsx | 9 +- src/webapp/contexts/app-context.ts | 2 - src/webapp/pages/app/App.tsx | 2 - .../CaseDataFileFieldHelper.ts | 155 ++++++++++++++ ...pDiseaseOutbreakEventToInitialFormState.ts | 72 ++++++- .../mapFormStateToDiseaseOutbreakEventData.ts | 189 ------------------ .../form-page/mapFormStateToEntityData.ts | 59 +++++- src/webapp/pages/form-page/useForm.ts | 5 +- .../pages/form-page/utils/FileHelper.ts | 39 ++++ ...State.ts => updateAndValidateFormState.ts} | 8 +- 46 files changed, 1231 insertions(+), 371 deletions(-) create mode 100644 src/data/repositories/CasesFileD2Repository.ts create mode 100644 src/data/repositories/consts/CaseDataConstants.ts create mode 100644 src/data/repositories/test/CasesFileTestRepository.ts create mode 100644 src/domain/entities/AlertsAndCaseForCasesData.ts create mode 100644 src/domain/entities/AppDatastoreConfig.ts create mode 100644 src/domain/entities/CasesFile.ts create mode 100644 src/domain/repositories/CasesFileRepository.ts delete mode 100644 src/domain/usecases/GetAllOrgUnitsUseCase.ts create mode 100644 src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts delete mode 100644 src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts create mode 100644 src/webapp/pages/form-page/utils/FileHelper.ts rename src/webapp/pages/form-page/utils/{updateDiseaseOutbreakEventFormState.ts => updateAndValidateFormState.ts} (89%) diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index d027c1d3..a28e597e 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -34,7 +34,6 @@ import { MapConfigD2Repository } from "./data/repositories/MapConfigD2Repository import { MapConfigTestRepository } from "./data/repositories/test/MapConfigTestRepository"; import { GetMapConfigUseCase } from "./domain/usecases/GetMapConfigUseCase"; import { GetProvincesOrgUnits } from "./domain/usecases/GetProvincesOrgUnits"; -import { GetAllOrgUnitsUseCase } from "./domain/usecases/GetAllOrgUnitsUseCase"; import { PerformanceOverviewRepository } from "./domain/repositories/PerformanceOverviewRepository"; import { GetAllPerformanceOverviewMetricsUseCase } from "./domain/usecases/GetAllPerformanceOverviewMetricsUseCase"; import { PerformanceOverviewD2Repository } from "./data/repositories/PerformanceOverviewD2Repository"; @@ -68,6 +67,9 @@ import { ConfigurationsRepository } from "./domain/repositories/ConfigurationsRe import { ConfigurationsD2Repository } from "./data/repositories/ConfigurationsD2Repository"; import { ConfigurationsTestRepository } from "./data/repositories/test/ConfigurationsTestRepository"; import { CompleteEventTrackerUseCase } from "./domain/usecases/CompleteEventTrackerUseCase"; +import { CasesFileD2Repository } from "./data/repositories/CasesFileD2Repository"; +import { CasesFileRepository } from "./domain/repositories/CasesFileRepository"; +import { CasesFileTestRepository } from "./data/repositories/test/CasesFileTestRepository"; export type CompositionRoot = ReturnType; @@ -87,6 +89,7 @@ type Repositories = { chartConfigRepository: ChartConfigRepository; systemRepository: SystemRepository; configurationsRepository: ConfigurationsRepository; + casesFileRepository: CasesFileRepository; }; function getCompositionRoot(repositories: Repositories) { @@ -105,7 +108,8 @@ function getCompositionRoot(repositories: Repositories) { ), getConfigurations: new GetConfigurationsUseCase( repositories.configurationsRepository, - repositories.teamMemberRepository + repositories.teamMemberRepository, + repositories.orgUnitRepository ), complete: new CompleteEventTrackerUseCase(repositories), }, @@ -133,7 +137,6 @@ function getCompositionRoot(repositories: Repositories) { getConfig: new GetMapConfigUseCase(repositories.mapConfigRepository), }, orgUnits: { - getAll: new GetAllOrgUnitsUseCase(repositories.orgUnitRepository), getProvinces: new GetProvincesOrgUnits(repositories.orgUnitRepository), }, charts: { @@ -160,6 +163,7 @@ export function getWebappCompositionRoot(api: D2Api) { chartConfigRepository: new ChartConfigD2Repository(dataStoreClient), systemRepository: new SystemD2Repository(api), configurationsRepository: new ConfigurationsD2Repository(api), + casesFileRepository: new CasesFileD2Repository(api, dataStoreClient), }; return getCompositionRoot(repositories); @@ -182,6 +186,7 @@ export function getTestCompositionRoot() { chartConfigRepository: new ChartConfigTestRepository(), systemRepository: new SystemTestRepository(), configurationsRepository: new ConfigurationsTestRepository(), + casesFileRepository: new CasesFileTestRepository(), }; return getCompositionRoot(repositories); diff --git a/src/data/repositories/AlertSyncDataStoreRepository.ts b/src/data/repositories/AlertSyncDataStoreRepository.ts index cf054678..72566348 100644 --- a/src/data/repositories/AlertSyncDataStoreRepository.ts +++ b/src/data/repositories/AlertSyncDataStoreRepository.ts @@ -6,10 +6,13 @@ import { AlertSyncRepository, } from "../../domain/repositories/AlertSyncRepository"; import { apiToFuture, FutureData } from "../api-futures"; -import { getOutbreakKey, getAlertValueFromMap } from "./utils/AlertOutbreakMapper"; +import { getAlertValueFromMap } from "./utils/AlertOutbreakMapper"; import { Maybe } from "../../utils/ts-utils"; import { DataValue } from "@eyeseetea/d2-api/api/trackerEvents"; -import { AlertSynchronizationData } from "../../domain/entities/alert/AlertData"; +import { + AlertsAndCaseForCasesData, + getOutbreakKey, +} from "../../domain/entities/AlertsAndCaseForCasesData"; import { DataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { RTSL_ZEBRA_ALERTS_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import { assertOrError } from "./utils/AssertOrError"; @@ -56,12 +59,15 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ); return this.getAlertObject(outbreakKey).flatMap(outbreakData => { - const syncData: AlertSynchronizationData = !outbreakData + const syncData: AlertsAndCaseForCasesData = !outbreakData ? synchronizationData : { ...outbreakData, - lastSyncTime: new Date().toISOString(), - alerts: [...outbreakData.alerts, ...synchronizationData.alerts], + lastUpdated: new Date().toISOString(), + alerts: [ + ...(outbreakData.alerts || []), + ...(synchronizationData.alerts || []), + ], }; return this.saveAlertObject(outbreakKey, syncData); @@ -88,22 +94,22 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ).flatMap(response => assertOrError(response.instances[0], "Tracked entity")); } - private getAlertObject(outbreakKey: string): FutureData> { - return this.dataStoreClient.getObject(outbreakKey); + private getAlertObject(outbreakKey: string): FutureData> { + return this.dataStoreClient.getObject(outbreakKey); } private saveAlertObject( outbreakKey: string, - syncData: AlertSynchronizationData + syncData: AlertsAndCaseForCasesData ): FutureData { - return this.dataStoreClient.saveObject(outbreakKey, syncData); + return this.dataStoreClient.saveObject(outbreakKey, syncData); } private buildSynchronizationData( options: AlertSyncOptions, trackedEntity: D2TrackerTrackedEntity, outbreakKey: string - ): AlertSynchronizationData { + ): AlertsAndCaseForCasesData { const { alert, nationalDiseaseOutbreakEventId, dataSource } = options; const outbreakType = dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS ? "disease" : "hazard"; @@ -127,8 +133,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ) ?? []; return { - lastSyncTime: new Date().toISOString(), - type: outbreakType, + lastUpdated: new Date().toISOString(), nationalDiseaseOutbreakEventId: nationalDiseaseOutbreakEventId, [outbreakType]: outbreakKey, alerts: alerts, diff --git a/src/data/repositories/CasesFileD2Repository.ts b/src/data/repositories/CasesFileD2Repository.ts new file mode 100644 index 00000000..ea08ddbe --- /dev/null +++ b/src/data/repositories/CasesFileD2Repository.ts @@ -0,0 +1,140 @@ +import { D2Api } from "@eyeseetea/d2-api/2.36"; +import { DataStoreClient } from "../DataStoreClient"; +import { apiToFuture, FutureData } from "../api-futures"; +import { CasesFileRepository } from "../../domain/repositories/CasesFileRepository"; +import { AlertsAndCaseForCasesData } from "../../domain/entities/AlertsAndCaseForCasesData"; +import { Maybe } from "../../utils/ts-utils"; +import { Id } from "../../domain/entities/Ref"; +import { Future } from "../../domain/entities/generic/Future"; +import { CaseFile } from "../../domain/entities/CasesFile"; +import { AppDatastoreConfig } from "../../domain/entities/AppDatastoreConfig"; + +export class CasesFileD2Repository implements CasesFileRepository { + constructor(private api: D2Api, private dataStoreClient: DataStoreClient) {} + + get(outbreakKey: Id): FutureData { + return this.getAlertsAndCaseForCasesDataObject(outbreakKey).flatMap( + alertsAndCaseForCasesData => { + if ( + !alertsAndCaseForCasesData?.case?.fileId || + !alertsAndCaseForCasesData?.case?.fileName || + !alertsAndCaseForCasesData?.case?.fileType + ) + return Future.error(new Error("No cases file id found")); + + return this.downloadCasesFile( + alertsAndCaseForCasesData.case.fileId, + alertsAndCaseForCasesData.case.fileName, + alertsAndCaseForCasesData.case.fileType + ); + } + ); + } + + getTemplate(): FutureData { + return this.dataStoreClient + .getObject("app-config") + .flatMap(appConfig => { + if ( + !appConfig?.casesFileTemplate?.fileId || + !appConfig?.casesFileTemplate?.fileName + ) + return Future.error(new Error("No cases file template found")); + + const { casesFileTemplate } = appConfig; + return this.downloadCasesFile( + casesFileTemplate.fileId, + casesFileTemplate.fileName, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + }); + } + + save(diseaseOutbreakEventId: Id, outbreakKey: string, caseFile: CaseFile): FutureData { + return Future.joinObj({ + fileId: this.uploadCasesFile(caseFile.file), + alertsAndCaseForCasesData: this.getAlertsAndCaseForCasesDataObject(outbreakKey), + }).flatMap(({ fileId, alertsAndCaseForCasesData }) => { + const newAlertsAndCaseForCasesData: AlertsAndCaseForCasesData = { + ...(alertsAndCaseForCasesData || {}), + lastUpdated: new Date().toISOString(), + nationalDiseaseOutbreakEventId: diseaseOutbreakEventId, + case: { + fileId, + fileName: caseFile.file.name, + fileType: caseFile.file.type, + }, + }; + return this.saveAlertsAndCaseForCasesDataObject( + outbreakKey, + newAlertsAndCaseForCasesData + ).flatMap(() => Future.success(undefined)); + }); + } + + delete(outbreakKey: Id): FutureData { + return this.getAlertsAndCaseForCasesDataObject(outbreakKey).flatMap( + alertsAndCaseForCasesData => { + if (!alertsAndCaseForCasesData?.case?.fileId) + return Future.error(new Error("No cases file id found")); + + return this.deleteCasesFile(alertsAndCaseForCasesData.case.fileId).flatMap(() => { + return this.saveAlertsAndCaseForCasesDataObject(outbreakKey, { + ...alertsAndCaseForCasesData, + lastUpdated: new Date().toISOString(), + case: undefined, + }); + }); + } + ); + } + + private downloadCasesFile( + fileId: Id, + fileName: string, + fileType: string + ): FutureData { + return apiToFuture(this.api.files.get(fileId)) + .map(blob => { + return new File([blob], fileName, { type: fileType }); + }) + .flatMap(file => { + return Future.success({ + fileId, + file, + }); + }); + } + + private uploadCasesFile(file: File): FutureData { + return apiToFuture( + this.api.files.upload({ + name: file.name, + data: file, + }) + ).map(response => response.id); + } + + private deleteCasesFile(fileId: Id): FutureData { + return apiToFuture(this.api.files.delete(fileId)).flatMap(response => { + if (response.httpStatus === "OK") return Future.success(undefined); + else return Future.error(new Error("Error while deleting cases file")); + }); + } + + private getAlertsAndCaseForCasesDataObject( + outbreakKey: string + ): FutureData> { + return this.dataStoreClient.getObject(outbreakKey); + } + + private saveAlertsAndCaseForCasesDataObject( + outbreakKey: string, + alertsAndCaseForCasesData: AlertsAndCaseForCasesData + ): FutureData { + return this.dataStoreClient.saveObject( + outbreakKey, + alertsAndCaseForCasesData + ); + } +} diff --git a/src/data/repositories/ConfigurationsD2Repository.ts b/src/data/repositories/ConfigurationsD2Repository.ts index 2282a4b0..186190b1 100644 --- a/src/data/repositories/ConfigurationsD2Repository.ts +++ b/src/data/repositories/ConfigurationsD2Repository.ts @@ -27,6 +27,7 @@ const optionSetCode: Record = { phoecLevel: "RTSL_ZEB_OS_PHOEC_ACT_LEVEL", status: "RTSL_ZEB_OS_STATUS", verification: "RTSL_ZEB_OS_VERIFICATION", + casesDataSource: "RTSL_ZEB_OS_CASE_DATA_SOURCE", }; export class ConfigurationsD2Repository implements ConfigurationsRepository { @@ -198,6 +199,13 @@ export class ConfigurationsD2Repository implements ConfigurationsRepository { if (verifications) selectableOptions.incidentResponseActionConfigurations.verification = this.mapD2OptionSetToOptions(verifications); + } else if (key === "casesDataSource") { + const casesDataSource = optionsResponse.optionSets.find( + optionSet => optionSet.code === value + ); + if (casesDataSource) + selectableOptions.eventTrackerConfigurations.casesDataSource = + this.mapD2OptionSetToOptions(casesDataSource); } }); @@ -215,6 +223,7 @@ export class ConfigurationsD2Repository implements ConfigurationsRepository { notificationSources: [], incidentStatus: [], incidentManagers: [], + casesDataSource: [], }, riskAssessmentGradingConfigurations: { populationAtRisk: [], diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index a9688a2b..ff17078e 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -1,7 +1,11 @@ import { D2Api } from "../../types/d2-api"; import { DiseaseOutbreakEventRepository } from "../../domain/repositories/DiseaseOutbreakEventRepository"; import { apiToFuture, FutureData } from "../api-futures"; -import { DiseaseOutbreakEventBaseAttrs } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CaseData, + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../../domain/entities/Ref"; import { mapDiseaseOutbreakEventToTrackedEntityAttributes, @@ -9,11 +13,25 @@ import { } from "./utils/DiseaseOutbreakMapper"; import { RTSL_ZEBRA_ORG_UNIT_ID, RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { getProgramTEAsMetadata } from "./utils/MetadataHelper"; +import { + D2ProgramStageDataElement, + getProgramDataElementsMetadata, + getProgramTEAsMetadata, +} from "./utils/MetadataHelper"; import { assertOrError } from "./utils/AssertOrError"; import { Future } from "../../domain/entities/generic/Future"; import { getAllTrackedEntitiesAsync } from "./utils/getAllTrackedEntities"; import { D2TrackerEnrollment } from "@eyeseetea/d2-api/api/trackerEnrollments"; +import { D2TrackerEvent } from "@eyeseetea/d2-api/api/trackerEvents"; +import { + CasesDataCode, + CasesDataKeyCode, + RTSL_ZEBRA_CASE_PROGRAM_ID, + RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID, + RTSL_ZEB_DET_NATIONAL_EVENT_ID_ID, + getCasesDataValuesFromDiseaseOutbreak, + isStringInCasesDataCodes, +} from "./consts/CaseDataConstants"; export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRepository { constructor(private api: D2Api) {} @@ -45,7 +63,9 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep }); } - save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData { + save(diseaseOutbreak: DiseaseOutbreakEvent, haveChangedCasesData?: boolean): FutureData { + const hasNewCasesData = !diseaseOutbreak.id && !!diseaseOutbreak.uploadedCasesData; + return getProgramTEAsMetadata(this.api, RTSL_ZEBRA_PROGRAM_ID).flatMap( teasMetadataResponse => { const teasMetadata = @@ -78,7 +98,26 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep ) ); } else { - return Future.success(diseaseOutbreakId); + const diseaseOutbreakWithId = new DiseaseOutbreakEvent({ + ...diseaseOutbreak, + id: diseaseOutbreakId, + }); + if (hasNewCasesData || haveChangedCasesData) { + if (haveChangedCasesData) { + // NOTICE: If the cases data has changed, we need to replace the old one with the new one + return this.deleteCasesData(diseaseOutbreakWithId).flatMap(() => { + return this.saveCasesData(diseaseOutbreakWithId).flatMap(() => + Future.success(diseaseOutbreakId) + ); + }); + } else { + return this.saveCasesData(diseaseOutbreakWithId).flatMap(() => + Future.success(diseaseOutbreakId) + ); + } + } else { + return Future.success(diseaseOutbreakId); + } } }); } @@ -125,5 +164,115 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep }); } + private getCasesDataByDiseaseOutbreakId(diseaseOutbreakId: Id): FutureData { + return apiToFuture( + this.api.tracker.events.get({ + program: RTSL_ZEBRA_CASE_PROGRAM_ID, + programStage: RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID, + fields: { + program: true, + orgUnit: true, + dataValues: { + dataElement: { id: true, code: true }, + value: true, + }, + event: true, + occurredAt: true, + status: true, + }, + filter: `${RTSL_ZEB_DET_NATIONAL_EVENT_ID_ID}:eq:${diseaseOutbreakId}`, + }) + ) + .flatMap(response => + assertOrError( + response.instances, + `Error fetching cases data for disease outbreak ${diseaseOutbreakId}` + ) + ) + .flatMap(d2Events => { + return Future.success(d2Events); + }); + } + + private saveCasesData(diseaseOutbreak: DiseaseOutbreakEvent): FutureData { + return getProgramDataElementsMetadata(this.api, RTSL_ZEBRA_CASE_PROGRAM_ID) + .flatMap(response => + assertOrError(response.objects[0], `Case program metadata not found`) + ) + .flatMap(programDataElementsMetadataResponse => { + const programDataElements: D2ProgramStageDataElement[] | undefined = + programDataElementsMetadataResponse.programStages.find( + programStage => programStage.id === RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID + )?.programStageDataElements; + + if (!diseaseOutbreak.uploadedCasesData || !programDataElements) + return Future.error( + new Error(`Cases data or case program data elements not found.`) + ); + + const d2TrackerEvents = diseaseOutbreak.uploadedCasesData.map(caseData => + this.mapCaseDataToD2TrackerEvents( + caseData, + diseaseOutbreak, + programDataElements + ) + ); + + return apiToFuture( + this.api.tracker.post({ importStrategy: "CREATE" }, { events: d2TrackerEvents }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error(`Error saving cases data: ${response.message}`) + ); + } else return Future.success(undefined); + }); + }); + } + + private deleteCasesData(diseaseOutbreak: DiseaseOutbreakEvent): FutureData { + return this.getCasesDataByDiseaseOutbreakId(diseaseOutbreak.id).flatMap(d2Events => { + return apiToFuture( + this.api.tracker.post({ importStrategy: "DELETE" }, { events: d2Events }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error(`Error deleting cases data: ${response.message}`) + ); + } else return Future.success(undefined); + }); + }); + } + + private mapCaseDataToD2TrackerEvents( + caseData: CaseData, + diseaseOutbreak: DiseaseOutbreakEvent, + programDataElements: D2ProgramStageDataElement[] + ): D2TrackerEvent { + const casesDataValuesByCode: Record = + getCasesDataValuesFromDiseaseOutbreak(caseData, diseaseOutbreak); + + const dataValues = programDataElements.map(({ dataElement }) => { + if (!isStringInCasesDataCodes(dataElement.code)) { + throw new Error("Data element code not found in cases data"); + } + const typedCode: CasesDataKeyCode = dataElement.code; + return { + dataElement: dataElement.id, + value: casesDataValuesByCode[typedCode], + }; + }); + + return { + event: "", + program: RTSL_ZEBRA_CASE_PROGRAM_ID, + programStage: RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID, + orgUnit: caseData.orgUnit, + occurredAt: caseData.reportDate, + status: "ACTIVE", + dataValues, + }; + } + //TO DO : Implement delete/archive after requirement confirmation } diff --git a/src/data/repositories/UserD2Repository.ts b/src/data/repositories/UserD2Repository.ts index 9c332064..7e5057ba 100644 --- a/src/data/repositories/UserD2Repository.ts +++ b/src/data/repositories/UserD2Repository.ts @@ -1,5 +1,6 @@ import { Future } from "../../domain/entities/generic/Future"; -import { AppDatastoreConfig, User } from "../../domain/entities/User"; +import { User } from "../../domain/entities/User"; +import { AppDatastoreConfig } from "../../domain/entities/AppDatastoreConfig"; import { UserRepository } from "../../domain/repositories/UserRepository"; import { D2Api, MetadataPick } from "../../types/d2-api"; import { apiToFuture, FutureData } from "../api-futures"; diff --git a/src/data/repositories/consts/CaseDataConstants.ts b/src/data/repositories/consts/CaseDataConstants.ts new file mode 100644 index 00000000..f688afd2 --- /dev/null +++ b/src/data/repositories/consts/CaseDataConstants.ts @@ -0,0 +1,57 @@ +import { GetValue } from "../../../utils/ts-utils"; +import { + CaseData, + DiseaseOutbreakEvent, +} from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { hazardTypeCodeMap } from "./DiseaseOutbreakConstants"; + +export const RTSL_ZEBRA_CASE_PROGRAM_ID = "A0fHWmkFPzX"; +export const RTSL_ZEBRA_CASE_PROGRAM_STAGE_ID = "aEUOfKt3cNP"; +export const RTSL_ZEB_DET_NATIONAL_EVENT_ID_ID = "ylPUzBomYdb"; + +export const casesDataCodes = { + hazardType: "RTSL_ZEB_DET_HAZARD_TYPE", + suspectedDisease: "RTSL_ZEB_DET_SUSPECTED_DISEASE", + lastUpdatedBy: "RTSL_ZEB_ALERTS_EVT_LAST_UPDATED_BY", + lastUpdatedAt: "RTSL_ZEB_ALERTS_EVT_LAST_UPDATED", + suspectedCases: "RTSL_ZEB_DET_SUS_CASES", + probableCases: "RTSL_ZEB_DET_PROB_CASES", + confirmedCases: "RTSL_ZEB_DET_CONF_CASES", + deaths: "RTSL_ZEB_DET_DEATHS", + diseaseOutbreakId: "RTSL_ZEB_DET_NATIONAL_EVENT_ID", +} as const; + +export type CasesDataCode = GetValue; + +export type CasesDataKeyCode = (typeof casesDataCodes)[keyof typeof casesDataCodes]; + +export function isStringInCasesDataCodes(code: string): code is CasesDataKeyCode { + return (Object.values(casesDataCodes) as string[]).includes(code); +} + +export function getCasesDataValuesFromDiseaseOutbreak( + caseData: CaseData, + diseaseOutbreak: DiseaseOutbreakEvent +): Record { + const nationalEventId = diseaseOutbreak.id; + const hazardTypeCode = diseaseOutbreak.hazardType + ? hazardTypeCodeMap[diseaseOutbreak.hazardType] + : ""; + const suspectedDiseaseCode = diseaseOutbreak.suspectedDiseaseCode ?? ""; + + if (!nationalEventId || (!hazardTypeCode && !suspectedDiseaseCode)) { + throw new Error("Missing required data for cases data"); + } + + return { + RTSL_ZEB_DET_HAZARD_TYPE: hazardTypeCode, + RTSL_ZEB_DET_SUSPECTED_DISEASE: suspectedDiseaseCode, + RTSL_ZEB_ALERTS_EVT_LAST_UPDATED_BY: caseData.updatedBy, + RTSL_ZEB_ALERTS_EVT_LAST_UPDATED: new Date().toISOString(), + RTSL_ZEB_DET_SUS_CASES: caseData.suspectedCases.toString(), + RTSL_ZEB_DET_PROB_CASES: caseData.probableCases.toString(), + RTSL_ZEB_DET_CONF_CASES: caseData.confirmedCases.toString(), + RTSL_ZEB_DET_DEATHS: caseData.deaths.toString(), + RTSL_ZEB_DET_NATIONAL_EVENT_ID: nationalEventId, + }; +} diff --git a/src/data/repositories/consts/DiseaseOutbreakConstants.ts b/src/data/repositories/consts/DiseaseOutbreakConstants.ts index ce57bc1c..60e8f11b 100644 --- a/src/data/repositories/consts/DiseaseOutbreakConstants.ts +++ b/src/data/repositories/consts/DiseaseOutbreakConstants.ts @@ -1,4 +1,5 @@ import { + CasesDataSource, DataSource, DiseaseOutbreakEventBaseAttrs, HazardType, @@ -48,6 +49,11 @@ export const dataSourceMap: Record = { RTSL_ZEB_OS_DATA_SOURCE_EBS: DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS, }; +export const casesDataSourceMap: Record = { + RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, + RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF, +}; + export const diseaseOutbreakCodes = { name: "RTSL_ZEB_TEA_EVENT_NAME", dataSource: "RTSL_ZEB_TEA_DATA_SOURCE", @@ -79,7 +85,7 @@ export const diseaseOutbreakCodes = { responseNarrative: "RTSL_ZEB_TEA_RESPONSE_NARRATIVE", incidentManager: "RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER", notes: "RTSL_ZEB_TEA_NOTES", - caseDataSource: "RTSL_ZEB_TEA_CASE_DATA_SOURCE", + casesDataSource: "RTSL_ZEB_TEA_CASE_DATA_SOURCE", } as const; export type DiseaseOutbreakCode = GetValue; @@ -165,7 +171,7 @@ export function getValueFromDiseaseOutbreak( RTSL_ZEB_TEA_RESPONSE_NARRATIVE: diseaseOutbreak.earlyResponseActions.responseNarrative, RTSL_ZEB_TEA_ASSIGN_INCIDENT_MANAGER: diseaseOutbreak.incidentManagerName, RTSL_ZEB_TEA_NOTES: diseaseOutbreak.notes ?? "", - RTSL_ZEB_TEA_CASE_DATA_SOURCE: "", + RTSL_ZEB_TEA_CASE_DATA_SOURCE: diseaseOutbreak.casesDataSource, }; } diff --git a/src/data/repositories/test/CasesFileTestRepository.ts b/src/data/repositories/test/CasesFileTestRepository.ts new file mode 100644 index 00000000..8f869714 --- /dev/null +++ b/src/data/repositories/test/CasesFileTestRepository.ts @@ -0,0 +1,29 @@ +import { CaseFile } from "../../../domain/entities/CasesFile"; +import { Future } from "../../../domain/entities/generic/Future"; +import { Id } from "../../../domain/entities/Ref"; +import { CasesFileRepository } from "../../../domain/repositories/CasesFileRepository"; +import { FutureData } from "../../api-futures"; + +export class CasesFileTestRepository implements CasesFileRepository { + get(_outbreakKey: Id): FutureData { + return Future.success({ + file: new File([], "test"), + fileId: "test", + }); + } + + getTemplate(): FutureData { + return Future.success({ + file: new File([], "test"), + fileId: "test", + }); + } + + save(_diseaseOutbreakEventId: Id, _outbreakKey: string, _file: CaseFile): FutureData { + return Future.success(undefined); + } + + delete(_outbreakKey: Id): FutureData { + return Future.success(undefined); + } +} diff --git a/src/data/repositories/test/ConfigurationsTestRepository.ts b/src/data/repositories/test/ConfigurationsTestRepository.ts index 445562da..4851de13 100644 --- a/src/data/repositories/test/ConfigurationsTestRepository.ts +++ b/src/data/repositories/test/ConfigurationsTestRepository.ts @@ -15,6 +15,7 @@ export class ConfigurationsTestRepository implements ConfigurationsRepository { notificationSources: [], incidentManagers: [], incidentStatus: [], + casesDataSource: [], }, riskAssessmentGradingConfigurations: { geographicalSpread: [], diff --git a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts index b7dc9d78..406660aa 100644 --- a/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts +++ b/src/data/repositories/test/DiseaseOutbreakEventTestRepository.ts @@ -1,4 +1,5 @@ import { + CasesDataSource, DataSource, DiseaseOutbreakEvent, DiseaseOutbreakEventBaseAttrs, @@ -44,6 +45,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + casesDataSource: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, }); } getAll(): FutureData { @@ -78,6 +80,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + casesDataSource: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, }, { id: "2", @@ -109,6 +112,7 @@ export class DiseaseOutbreakEventTestRepository implements DiseaseOutbreakEventR }, incidentManagerName: "incidentManager", notes: undefined, + casesDataSource: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, }, ]); } diff --git a/src/data/repositories/utils/AlertOutbreakMapper.ts b/src/data/repositories/utils/AlertOutbreakMapper.ts index 0ee08d1b..e1af0751 100644 --- a/src/data/repositories/utils/AlertOutbreakMapper.ts +++ b/src/data/repositories/utils/AlertOutbreakMapper.ts @@ -1,8 +1,5 @@ import { D2TrackerTrackedEntity } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; -import { DataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { AlertOptions } from "../../../domain/repositories/AlertRepository"; -import { Maybe } from "../../../utils/ts-utils"; -import { Option } from "../../../domain/entities/Ref"; import { alertOutbreakCodes } from "../consts/AlertConstants"; import { getValueFromMap } from "./DiseaseOutbreakMapper"; import { @@ -53,24 +50,3 @@ export function getAlertValueFromMap( ?.value ?? "" ); } - -export function getOutbreakKey(options: { - dataSource: DataSource; - outbreakValue: Maybe; - hazardTypes: Option[]; - suspectedDiseases: Option[]; -}): string { - const { dataSource, outbreakValue, hazardTypes, suspectedDiseases } = options; - - const diseaseName = suspectedDiseases.find(disease => disease.id === outbreakValue)?.name; - const hazardName = hazardTypes.find(hazardType => hazardType.id === outbreakValue)?.name; - - if (!diseaseName && !hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); - - switch (dataSource) { - case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: - return hazardName ?? ""; - case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: - return diseaseName ?? ""; - } -} diff --git a/src/data/repositories/utils/DiseaseOutbreakMapper.ts b/src/data/repositories/utils/DiseaseOutbreakMapper.ts index de178d10..2cb3f179 100644 --- a/src/data/repositories/utils/DiseaseOutbreakMapper.ts +++ b/src/data/repositories/utils/DiseaseOutbreakMapper.ts @@ -1,4 +1,7 @@ -import { DiseaseOutbreakEventBaseAttrs } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEventBaseAttrs, +} from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { D2TrackerTrackedEntity, Attribute } from "@eyeseetea/d2-api/api/trackerTrackedEntities"; import { DiseaseOutbreakCode, @@ -12,6 +15,7 @@ import { RTSL_ZEBRA_ORG_UNIT_ID, RTSL_ZEBRA_PROGRAM_ID, RTSL_ZEBRA_TRACKED_ENTITY_TYPE_ID, + casesDataSourceMap, } from "../consts/DiseaseOutbreakConstants"; import _ from "../../../domain/entities/generic/Collection"; import { SelectedPick } from "@eyeseetea/d2-api/api"; @@ -39,6 +43,7 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( const dataSource = dataSourceMap[fromMap("dataSource")]; const incidentStatus = incidentStatusMap[fromMap("incidentStatus")]; + const casesDataSource = casesDataSourceMap[fromMap("casesDataSource")]; if (!dataSource || !incidentStatus) throw new Error("Data source or incident status not valid"); @@ -95,6 +100,7 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( responseNarrative: fromMap("responseNarrative"), }, notes: fromMap("notes"), + casesDataSource: casesDataSource ?? CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, // To-do: check if this is correct }; return diseaseOutbreak; diff --git a/src/domain/entities/AlertsAndCaseForCasesData.ts b/src/domain/entities/AlertsAndCaseForCasesData.ts new file mode 100644 index 00000000..836b311d --- /dev/null +++ b/src/domain/entities/AlertsAndCaseForCasesData.ts @@ -0,0 +1,49 @@ +import { Maybe } from "../../utils/ts-utils"; +import { DataSource } from "./disease-outbreak-event/DiseaseOutbreakEvent"; +import { Id, Option } from "./Ref"; + +export type AlertsAndCaseForCasesData = { + lastUpdated: string; + nationalDiseaseOutbreakEventId: Id; + alerts?: { + alertId: string; + eventDate: Maybe; + orgUnit: Maybe; + suspectedCases: string; + probableCases: string; + confirmedCases: string; + deaths: string; + }[]; + case?: { + fileId: Id; + fileName: string; + fileType: string; + }; +} & { + [key in "disease" | "hazard"]?: string; +}; + +export function getOutbreakKey(options: { + dataSource: DataSource; + outbreakValue: Maybe; + hazardTypes: Option[]; + suspectedDiseases: Option[]; +}): string { + const { dataSource, outbreakValue, hazardTypes, suspectedDiseases } = options; + + const diseaseName = suspectedDiseases.find(disease => disease.id === outbreakValue)?.name; + const hazardName = hazardTypes.find(hazardType => hazardType.id === outbreakValue)?.name; + + switch (dataSource) { + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: + if (!hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + return hazardName; + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: + if (!diseaseName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + return diseaseName; + default: + throw new Error(`Unknown data source: ${dataSource}`); + } +} diff --git a/src/domain/entities/AppConfigurations.ts b/src/domain/entities/AppConfigurations.ts index 5e531355..d48997cb 100644 --- a/src/domain/entities/AppConfigurations.ts +++ b/src/domain/entities/AppConfigurations.ts @@ -6,6 +6,7 @@ import { RiskAssessmentSummaryOptions, } from "./ConfigurableForm"; import { TeamMember } from "./incident-management-team/TeamMember"; +import { OrgUnit } from "./OrgUnit"; import { LowPopulationAtRisk, @@ -50,4 +51,5 @@ export type Configurations = { incidentManagers: TeamMember[]; responseOfficers: TeamMember[]; }; + orgUnits: OrgUnit[]; }; diff --git a/src/domain/entities/AppDatastoreConfig.ts b/src/domain/entities/AppDatastoreConfig.ts new file mode 100644 index 00000000..49ff5441 --- /dev/null +++ b/src/domain/entities/AppDatastoreConfig.ts @@ -0,0 +1,13 @@ +import { Id } from "./Ref"; + +export type AppDatastoreConfig = { + userGroups: { + visualizer: string[]; + capture: string[]; + admin: string[]; + }; + casesFileTemplate: { + fileId: Id; + fileName: string; + }; +}; diff --git a/src/domain/entities/CasesFile.ts b/src/domain/entities/CasesFile.ts new file mode 100644 index 00000000..3feb2015 --- /dev/null +++ b/src/domain/entities/CasesFile.ts @@ -0,0 +1,30 @@ +import { Maybe } from "../../utils/ts-utils"; +import { DataSource } from "./disease-outbreak-event/DiseaseOutbreakEvent"; +import { Id } from "./Ref"; +import { Option } from "./Ref"; + +export type CaseFile = { + fileId?: Id; + file: File; +}; + +export function getOutbreakKeyForCases(options: { + dataSource: DataSource; + outbreakValue: Maybe; + hazardTypes: Option[]; + suspectedDiseases: Option[]; +}): string { + const { dataSource, outbreakValue, hazardTypes, suspectedDiseases } = options; + + const diseaseName = suspectedDiseases.find(disease => disease.id === outbreakValue)?.name; + const hazardName = hazardTypes.find(hazardType => hazardType.id === outbreakValue)?.name; + + if (!diseaseName && !hazardName) throw new Error(`Outbreak not found for ${outbreakValue}`); + + switch (dataSource) { + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS: + return hazardName ?? ""; + case DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS: + return diseaseName ?? ""; + } +} diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index 2434d344..e389ff23 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -2,10 +2,7 @@ import { Maybe } from "../../utils/ts-utils"; import { TeamMember } from "./incident-management-team/TeamMember"; import { Id, Option } from "./Ref"; import { Rule } from "./Rule"; -import { - DiseaseOutbreakEvent, - DiseaseOutbreakEventBaseAttrs, -} from "./disease-outbreak-event/DiseaseOutbreakEvent"; +import { DiseaseOutbreakEvent } from "./disease-outbreak-event/DiseaseOutbreakEvent"; import { FormType } from "../../webapp/pages/form-page/FormPage"; import { RiskAssessmentGrading } from "./risk-assessment/RiskAssessmentGrading"; import { RiskAssessmentSummary } from "./risk-assessment/RiskAssessmentSummary"; @@ -14,6 +11,7 @@ import { ActionPlanAttrs } from "./incident-action-plan/ActionPlan"; import { ResponseAction } from "./incident-action-plan/ResponseAction"; import { IncidentManagementTeam } from "./incident-management-team/IncidentManagementTeam"; import { Role } from "./incident-management-team/Role"; +import { OrgUnit } from "./OrgUnit"; export type DiseaseOutbreakEventOptions = { dataSources: Option[]; @@ -23,6 +21,7 @@ export type DiseaseOutbreakEventOptions = { notificationSources: Option[]; incidentStatus: Option[]; incidentManagers: TeamMember[]; + casesDataSource: Option[]; }; export type RiskAssessmentGradingOptions = { @@ -74,8 +73,13 @@ type BaseFormData = { }; export type DiseaseOutbreakEventFormData = BaseFormData & { type: "disease-outbreak-event"; - entity: Maybe; + entity: Maybe; options: DiseaseOutbreakEventOptions; + orgUnits: OrgUnit[]; + caseDataFileTemplete: File; + uploadedCasesDataFile: Maybe; + uploadedCasesDataFileId: Maybe; + hasInitiallyCasesDataFile: boolean; }; export type RiskAssessmentGradingFormData = BaseFormData & { diff --git a/src/domain/entities/User.ts b/src/domain/entities/User.ts index 714b5712..09df6d1d 100644 --- a/src/domain/entities/User.ts +++ b/src/domain/entities/User.ts @@ -1,10 +1,11 @@ import { Struct } from "./generic/Struct"; import { NamedRef } from "./Ref"; +export type Username = string; export interface UserAttrs { id: string; name: string; - username: string; + username: Username; userRoles: UserRole[]; userGroups: NamedRef[]; hasCaptureAccess: boolean; @@ -13,13 +14,6 @@ export interface UserAttrs { export interface UserRole extends NamedRef { authorities: string[]; } -export type AppDatastoreConfig = { - userGroups: { - visualizer: string[]; - capture: string[]; - admin: string[]; - }; -}; export class User extends Struct() { belongToUserGroup(userGroupUid: string): boolean { diff --git a/src/domain/entities/ValidationError.ts b/src/domain/entities/ValidationError.ts index 8988c51a..be17c8bf 100644 --- a/src/domain/entities/ValidationError.ts +++ b/src/domain/entities/ValidationError.ts @@ -3,10 +3,18 @@ import { Maybe } from "../../utils/ts-utils"; export type ValidationErrorKey = | "field_is_required" | "field_is_required_na" - | "cannot_create_cyclycal_dependency"; + | "cannot_create_cyclycal_dependency" + | "file_missing" + | "file_empty" + | "file_headers_missing" + | "file_dates_missing" + | "file_dates_not_unique" + | "file_org_units_incorrect" + | "file_data_not_number"; export type ValidationError = { property: string; - value: string | boolean | Date | Maybe | string[] | null; + value: string | boolean | Date | Maybe | string[] | null | Maybe; errors: ValidationErrorKey[]; + errorsInFile?: Partial>; }; diff --git a/src/domain/entities/alert/AlertData.ts b/src/domain/entities/alert/AlertData.ts index dade4a44..f7a3f1d3 100644 --- a/src/domain/entities/alert/AlertData.ts +++ b/src/domain/entities/alert/AlertData.ts @@ -1,6 +1,4 @@ -import { Maybe } from "../../../utils/ts-utils"; import { DataSource } from "../disease-outbreak-event/DiseaseOutbreakEvent"; -import { Id } from "../Ref"; import { Alert } from "./Alert"; export type AlertData = { @@ -11,20 +9,3 @@ export type AlertData = { value: string; }; }; - -export type AlertSynchronizationData = { - lastSyncTime: string; - type: string; - nationalDiseaseOutbreakEventId: Id; - alerts: { - alertId: string; - eventDate: Maybe; - orgUnit: Maybe; - suspectedCases: string; - probableCases: string; - confirmedCases: string; - deaths: string; - }[]; -} & { - [key in "disease" | "hazard"]?: string; -}; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index d6cbf1c4..f3c0bb22 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -2,10 +2,12 @@ import { Struct } from "../generic/Struct"; import { IncidentActionPlan } from "../incident-action-plan/IncidentActionPlan"; import { IncidentManagementTeam } from "../incident-management-team/IncidentManagementTeam"; import { TeamMember } from "../incident-management-team/TeamMember"; -import { Code, NamedRef } from "../Ref"; +import { Code, Id, NamedRef } from "../Ref"; import { RiskAssessment } from "../risk-assessment/RiskAssessment"; import { Maybe } from "../../../utils/ts-utils"; import { ValidationError } from "../ValidationError"; +import _ from "../generic/Collection"; +import { Username } from "../User"; export const hazardTypes = [ "Biological:Human", @@ -31,6 +33,11 @@ export enum DataSource { RTSL_ZEB_OS_DATA_SOURCE_EBS = "RTSL_ZEB_OS_DATA_SOURCE_EBS", } +export enum CasesDataSource { + RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR = "RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR", + RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF = "RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF", +} + type DateWithNarrative = { date: Date; narrative: string; @@ -52,6 +59,16 @@ type EarlyResponseActions = { responseNarrative: string; }; +export type CaseData = { + updatedBy: Username; + orgUnit: Id; + reportDate: string; + suspectedCases: number; + probableCases: number; + confirmedCases: number; + deaths: number; +}; + export type DiseaseOutbreakEventBaseAttrs = NamedRef & { status: "ACTIVE" | "COMPLETED" | "CANCELLED"; created?: Date; @@ -69,17 +86,19 @@ export type DiseaseOutbreakEventBaseAttrs = NamedRef & { earlyResponseActions: EarlyResponseActions; incidentManagerName: string; notes: Maybe; + casesDataSource: Code; }; export type DiseaseOutbreakEventAttrs = DiseaseOutbreakEventBaseAttrs & { createdBy: Maybe; mainSyndrome: Maybe; suspectedDisease: Maybe; - notificationSource: NamedRef; + notificationSource: Maybe; incidentManager: Maybe; //TO DO : make mandatory once form rules applied. riskAssessment: Maybe; incidentActionPlan: Maybe; incidentManagementTeam: Maybe; + uploadedCasesData: Maybe; }; /** @@ -92,4 +111,8 @@ export class DiseaseOutbreakEvent extends Struct() { static validate(_data: DiseaseOutbreakEventBaseAttrs): ValidationError[] { return []; } + + addUploadedCasesData(casesData: CaseData[]): DiseaseOutbreakEvent { + return this._update({ uploadedCasesData: casesData }); + } } diff --git a/src/domain/entities/incident-management-team/TeamMember.ts b/src/domain/entities/incident-management-team/TeamMember.ts index b32c0a62..e7377247 100644 --- a/src/domain/entities/incident-management-team/TeamMember.ts +++ b/src/domain/entities/incident-management-team/TeamMember.ts @@ -1,5 +1,6 @@ import { Maybe } from "../../../utils/ts-utils"; import { NamedRef } from "../Ref"; +import { Username } from "../User"; import { Struct } from "../generic/Struct"; type PhoneNumber = string; @@ -12,7 +13,7 @@ export type TeamRole = NamedRef & { }; interface TeamMemberAttrs extends NamedRef { - username: string; + username: Username; phone: Maybe; email: Maybe; status: Maybe; diff --git a/src/domain/repositories/CasesFileRepository.ts b/src/domain/repositories/CasesFileRepository.ts new file mode 100644 index 00000000..803c0a59 --- /dev/null +++ b/src/domain/repositories/CasesFileRepository.ts @@ -0,0 +1,10 @@ +import { FutureData } from "../../data/api-futures"; +import { CaseFile } from "../entities/CasesFile"; +import { Id } from "../entities/Ref"; + +export interface CasesFileRepository { + get(outbreakKey: Id): FutureData; + getTemplate(): FutureData; + save(diseaseOutbreakEventId: Id, outbreakKey: string, file: CaseFile): FutureData; + delete(outbreakKey: Id): FutureData; +} diff --git a/src/domain/repositories/DiseaseOutbreakEventRepository.ts b/src/domain/repositories/DiseaseOutbreakEventRepository.ts index 63a5ff33..1e874712 100644 --- a/src/domain/repositories/DiseaseOutbreakEventRepository.ts +++ b/src/domain/repositories/DiseaseOutbreakEventRepository.ts @@ -1,10 +1,13 @@ import { FutureData } from "../../data/api-futures"; -import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Id } from "../entities/Ref"; export interface DiseaseOutbreakEventRepository { get(id: Id): FutureData; getAll(): FutureData; - save(diseaseOutbreak: DiseaseOutbreakEventBaseAttrs): FutureData; + save(diseaseOutbreak: DiseaseOutbreakEvent, haveChangedCasesData?: boolean): FutureData; complete(id: Id): FutureData; } diff --git a/src/domain/usecases/GetAllOrgUnitsUseCase.ts b/src/domain/usecases/GetAllOrgUnitsUseCase.ts deleted file mode 100644 index fcb25711..00000000 --- a/src/domain/usecases/GetAllOrgUnitsUseCase.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FutureData } from "../../data/api-futures"; -import { OrgUnit } from "../entities/OrgUnit"; -import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; - -export class GetAllOrgUnitsUseCase { - constructor(private orgUnitRepository: OrgUnitRepository) {} - - public execute(): FutureData { - return this.orgUnitRepository.getAll(); - } -} diff --git a/src/domain/usecases/GetConfigurableFormUseCase.ts b/src/domain/usecases/GetConfigurableFormUseCase.ts index bb842569..7067af0f 100644 --- a/src/domain/usecases/GetConfigurableFormUseCase.ts +++ b/src/domain/usecases/GetConfigurableFormUseCase.ts @@ -6,6 +6,7 @@ import { ConfigurableForm } from "../entities/ConfigurableForm"; import { DiseaseOutbreakEvent } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; +import { CasesFileRepository } from "../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; import { IncidentActionRepository } from "../repositories/IncidentActionRepository"; import { IncidentManagementTeamRepository } from "../repositories/IncidentManagementTeamRepository"; @@ -27,6 +28,7 @@ export class GetConfigurableFormUseCase { teamMemberRepository: TeamMemberRepository; incidentActionRepository: IncidentActionRepository; incidentManagementTeamRepository: IncidentManagementTeamRepository; + casesFileRepository: CasesFileRepository; } ) {} diff --git a/src/domain/usecases/GetConfigurationsUseCase.ts b/src/domain/usecases/GetConfigurationsUseCase.ts index 7b6fd86f..2536ac69 100644 --- a/src/domain/usecases/GetConfigurationsUseCase.ts +++ b/src/domain/usecases/GetConfigurationsUseCase.ts @@ -3,12 +3,14 @@ import { Configurations, SelectableOptions } from "../entities/AppConfigurations import { Future } from "../entities/generic/Future"; import { TeamMember } from "../entities/incident-management-team/TeamMember"; import { ConfigurationsRepository } from "../repositories/ConfigurationsRepository"; +import { OrgUnitRepository } from "../repositories/OrgUnitRepository"; import { TeamMemberRepository } from "../repositories/TeamMemberRepository"; export class GetConfigurationsUseCase { constructor( private configurationsRepository: ConfigurationsRepository, - private teamMemberRepository: TeamMemberRepository + private teamMemberRepository: TeamMemberRepository, + private orgUnitRepository: OrgUnitRepository ) {} public execute(): FutureData { @@ -18,6 +20,7 @@ export class GetConfigurationsUseCase { managers: this.teamMemberRepository.getIncidentManagers(), riskAssessors: this.teamMemberRepository.getRiskAssessors(), selectableOptionsResponse: this.configurationsRepository.getSelectableOptions(), + orgUnits: this.orgUnitRepository.getAll(), }).flatMap( ({ allTeamMembers, @@ -25,6 +28,7 @@ export class GetConfigurationsUseCase { managers, riskAssessors, selectableOptionsResponse, + orgUnits, }) => { const selectableOptions: SelectableOptions = this.mapOptionsAndTeamMembersToSelectableOptions( @@ -42,6 +46,7 @@ export class GetConfigurationsUseCase { incidentManagers: managers, responseOfficers: incidentResponseOfficers, }, + orgUnits, }; return Future.success(configurations); } diff --git a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts index 93fa90e1..a7bca345 100644 --- a/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts +++ b/src/domain/usecases/GetDiseaseOutbreakByIdUseCase.ts @@ -81,6 +81,7 @@ export class GetDiseaseOutbreakByIdUseCase { riskAssessment: riskAssessment, incidentActionPlan: undefined, //IAP is fetched on menu click. It is not needed here. incidentManagementTeam: undefined, //IMT is fetched on menu click. It is not needed here. + uploadedCasesData: undefined, // Uploaded case data is not needed }); return Future.success(diseaseOutbreakEvent); }); diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 629708eb..1b7ddba7 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -1,7 +1,10 @@ import { FutureData } from "../../data/api-futures"; import { INCIDENT_MANAGER_ROLE } from "../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; import { ConfigurableForm } from "../entities/ConfigurableForm"; -import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../entities/generic/Future"; import { Id } from "../entities/Ref"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; @@ -13,6 +16,7 @@ import { saveDiseaseOutbreak } from "./utils/disease-outbreak/SaveDiseaseOutbrea import { RoleRepository } from "../repositories/RoleRepository"; import { Configurations } from "../entities/AppConfigurations"; import moment from "moment"; +import { CasesFileRepository } from "../repositories/CasesFileRepository"; export class SaveEntityUseCase { constructor( @@ -23,27 +27,44 @@ export class SaveEntityUseCase { incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; roleRepository: RoleRepository; + casesFileRepository: CasesFileRepository; } ) {} public execute( formData: ConfigurableForm, - configurations: Configurations + configurations: Configurations, + editMode: boolean ): FutureData { if (!formData || !formData.entity) return Future.error(new Error("No form data found")); switch (formData.type) { - case "disease-outbreak-event": + case "disease-outbreak-event": { + const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...formData.entity, + + // NOTICE: Not needed for saving + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); + return saveDiseaseOutbreak( + this.options, + diseaseOutbreakEvent, + configurations, + editMode, { - diseaseOutbreakEventRepository: this.options.diseaseOutbreakEventRepository, - incidentManagementTeamRepository: - this.options.incidentManagementTeamRepository, - teamMemberRepository: this.options.teamMemberRepository, - roleRepository: this.options.roleRepository, - }, - formData.entity, - configurations + uploadedCasesDataFile: formData.uploadedCasesDataFile, + uploadedCasesDataFileId: formData.uploadedCasesDataFileId, + hasInitiallyCasesDataFile: formData.hasInitiallyCasesDataFile, + } ); + } case "risk-assessment-grading": case "risk-assessment-summary": case "risk-assessment-questionnaire": @@ -80,17 +101,26 @@ export class SaveEntityUseCase { incidentManagerName: updatedIncidentManager, }; + const diseaseOutbreakEvent: DiseaseOutbreakEvent = + new DiseaseOutbreakEvent({ + ...updatedDiseaseOutbreakEvent, + + // NOTICE: Not needed for saving + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + uploadedCasesData: undefined, + }); return saveDiseaseOutbreak( - { - diseaseOutbreakEventRepository: - this.options.diseaseOutbreakEventRepository, - incidentManagementTeamRepository: - this.options.incidentManagementTeamRepository, - teamMemberRepository: this.options.teamMemberRepository, - roleRepository: this.options.roleRepository, - }, - updatedDiseaseOutbreakEvent, - configurations + this.options, + diseaseOutbreakEvent, + configurations, + editMode ); } else { return Future.success(undefined); diff --git a/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts b/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts index 6f384401..35913a1b 100644 --- a/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts +++ b/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts @@ -1,40 +1,103 @@ import { FutureData } from "../../../../data/api-futures"; import { DiseaseOutbreakEventFormData, FormLables } from "../../../entities/ConfigurableForm"; -import { DataSource } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DataSource, + DiseaseOutbreakEvent, +} from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; import { Id } from "../../../entities/Ref"; import { Rule } from "../../../entities/Rule"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; import { Configurations } from "../../../entities/AppConfigurations"; +import { CasesFileRepository } from "../../../repositories/CasesFileRepository"; +import { getOutbreakKey } from "../../../entities/AlertsAndCaseForCasesData"; export function getDiseaseOutbreakConfigurableForm( options: { diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + casesFileRepository: CasesFileRepository; }, configurations: Configurations, id?: Id ): FutureData { const { rules, labels } = getEventTrackerLabelsRules(); - const diseaseOutbreakForm: DiseaseOutbreakEventFormData = { - type: "disease-outbreak-event", - entity: undefined, - rules: rules, - labels: labels, - options: configurations.selectableOptions.eventTrackerConfigurations, - }; + return options.casesFileRepository.getTemplate().flatMap(casesFileTemplate => { + const diseaseOutbreakForm: DiseaseOutbreakEventFormData = { + type: "disease-outbreak-event", + entity: undefined, + uploadedCasesDataFile: undefined, + uploadedCasesDataFileId: undefined, + hasInitiallyCasesDataFile: false, + caseDataFileTemplete: casesFileTemplate.file, + rules: rules, + labels: labels, + options: configurations.selectableOptions.eventTrackerConfigurations, + orgUnits: configurations.orgUnits, + }; + + if (id) { + return options.diseaseOutbreakEventRepository + .get(id) + .flatMap(diseaseOutbreakEventBase => { + const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + + // NOTICE: Not needed in form but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + uploadedCasesData: undefined, + }); + + const outbreakKey = getOutbreakKey({ + dataSource: diseaseOutbreakEvent.dataSource, + outbreakValue: + diseaseOutbreakEvent.suspectedDiseaseCode || + diseaseOutbreakEvent.hazardType, + hazardTypes: + configurations.selectableOptions.eventTrackerConfigurations.hazardTypes, + suspectedDiseases: + configurations.selectableOptions.eventTrackerConfigurations + .suspectedDiseases, + }); + + const hasCasesDataFile = + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; - if (id) { - return options.diseaseOutbreakEventRepository.get(id).flatMap(diseaseOutbreakEventBase => { - const populatedDiseaseOutbreakForm: DiseaseOutbreakEventFormData = { - ...diseaseOutbreakForm, - entity: diseaseOutbreakEventBase, - }; - return Future.success(populatedDiseaseOutbreakForm); - }); - } else { - return Future.success(diseaseOutbreakForm); - } + const populatedDiseaseOutbreakForm: DiseaseOutbreakEventFormData = { + ...diseaseOutbreakForm, + entity: diseaseOutbreakEvent, + }; + + if (hasCasesDataFile) { + return options.casesFileRepository + .get(outbreakKey) + .flatMap(casesDataFile => { + const populatedDiseaseOutbreakFormWithFile: DiseaseOutbreakEventFormData = + { + ...populatedDiseaseOutbreakForm, + uploadedCasesDataFile: casesDataFile.file, + uploadedCasesDataFileId: casesDataFile.fileId, + hasInitiallyCasesDataFile: true, + }; + return Future.success(populatedDiseaseOutbreakFormWithFile); + }); + } else { + return Future.success(populatedDiseaseOutbreakForm); + } + }); + } else { + return Future.success(diseaseOutbreakForm); + } + }); } function getEventTrackerLabelsRules(): { rules: Rule[]; labels: FormLables } { @@ -44,6 +107,8 @@ function getEventTrackerLabelsRules(): { rules: Rule[]; labels: FormLables } { errors: { field_is_required: "This field is required", field_is_required_na: "This field is required when not applicable", + file_missing: "File is missing", + file_empty: "File is empty", }, }, // TODO: Get rules from Datastore used in applyRulesInFormState @@ -60,6 +125,12 @@ function getEventTrackerLabelsRules(): { rules: Rule[]; labels: FormLables } { fieldValue: DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS, sectionIds: ["mainSyndrome_section", "suspectedDisease_section"], }, + { + type: "toggleSectionsVisibilityByFieldValue", + fieldId: "casesDataSource", + fieldValue: CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF, + sectionIds: ["casesDataFile_section"], + }, ], }; } diff --git a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts index 4bb144ec..9ea485df 100644 --- a/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts +++ b/src/domain/usecases/utils/disease-outbreak/SaveDiseaseOutbreak.ts @@ -1,15 +1,22 @@ import { FutureData } from "../../../../data/api-futures"; import { INCIDENT_MANAGER_ROLE } from "../../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { getOutbreakKey } from "../../../entities/AlertsAndCaseForCasesData"; import { Configurations } from "../../../entities/AppConfigurations"; -import { DiseaseOutbreakEventBaseAttrs } from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEvent, + DiseaseOutbreakEventBaseAttrs, +} from "../../../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../entities/generic/Future"; import { Role } from "../../../entities/incident-management-team/Role"; import { TeamMember, TeamRole } from "../../../entities/incident-management-team/TeamMember"; import { Id } from "../../../entities/Ref"; +import { CasesFileRepository } from "../../../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../../../repositories/DiseaseOutbreakEventRepository"; import { IncidentManagementTeamRepository } from "../../../repositories/IncidentManagementTeamRepository"; import { RoleRepository } from "../../../repositories/RoleRepository"; import { TeamMemberRepository } from "../../../repositories/TeamMemberRepository"; +import { Maybe } from "../../../../utils/ts-utils"; export function saveDiseaseOutbreak( repositories: { @@ -17,19 +24,75 @@ export function saveDiseaseOutbreak( incidentManagementTeamRepository: IncidentManagementTeamRepository; teamMemberRepository: TeamMemberRepository; roleRepository: RoleRepository; + casesFileRepository: CasesFileRepository; }, - diseaseOutbreakEvent: DiseaseOutbreakEventBaseAttrs, - configurations: Configurations + diseaseOutbreakEvent: DiseaseOutbreakEvent, + configurations: Configurations, + editMode: boolean, + casesDataOptions?: { + uploadedCasesDataFile: Maybe; + uploadedCasesDataFileId: Maybe; + hasInitiallyCasesDataFile: boolean; + } ): FutureData { + const { uploadedCasesDataFile, uploadedCasesDataFileId, hasInitiallyCasesDataFile } = + casesDataOptions || {}; + + const hasNewCasesData = + (!editMode || !hasInitiallyCasesDataFile) && + !!uploadedCasesDataFile && + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + const haveChangedCasesData = + editMode && + hasInitiallyCasesDataFile && + !uploadedCasesDataFileId && + !!uploadedCasesDataFile && + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + return repositories.diseaseOutbreakEventRepository - .save(diseaseOutbreakEvent) + .save(diseaseOutbreakEvent, haveChangedCasesData) .flatMap((diseaseOutbreakId: Id) => { const diseaseOutbreakEventWithId = { ...diseaseOutbreakEvent, id: diseaseOutbreakId }; return saveIncidentManagerTeamMemberRole( repositories, diseaseOutbreakEventWithId, configurations - ); + ).flatMap(() => { + if (hasNewCasesData || haveChangedCasesData) { + const outbreakKey = getOutbreakKey({ + dataSource: diseaseOutbreakEventWithId.dataSource, + outbreakValue: + diseaseOutbreakEventWithId.suspectedDiseaseCode || + diseaseOutbreakEventWithId.hazardType, + hazardTypes: + configurations.selectableOptions.eventTrackerConfigurations.hazardTypes, + suspectedDiseases: + configurations.selectableOptions.eventTrackerConfigurations + .suspectedDiseases, + }); + + if (haveChangedCasesData) { + // NOTICE: If the cases data file has changed, we need to replace the old one with the new one + return repositories.casesFileRepository.delete(outbreakKey).flatMap(() => { + return repositories.casesFileRepository + .save(diseaseOutbreakEvent.id, outbreakKey, { + file: uploadedCasesDataFile, + }) + .flatMap(() => Future.success(diseaseOutbreakId)); + }); + } else { + return repositories.casesFileRepository + .save(diseaseOutbreakEvent.id, outbreakKey, { + file: uploadedCasesDataFile, + }) + .flatMap(() => Future.success(diseaseOutbreakId)); + } + } else { + return Future.success(diseaseOutbreakId); + } + }); }); } diff --git a/src/scripts/mapDiseaseOutbreakToAlerts.ts b/src/scripts/mapDiseaseOutbreakToAlerts.ts index 53f4597a..54b92691 100644 --- a/src/scripts/mapDiseaseOutbreakToAlerts.ts +++ b/src/scripts/mapDiseaseOutbreakToAlerts.ts @@ -16,10 +16,7 @@ import { NotificationD2Repository } from "../data/repositories/NotificationD2Rep import { Future } from "../domain/entities/generic/Future"; import { getTEAttributeById, getUserGroupByCode } from "../data/repositories/utils/MetadataHelper"; import { NotifyWatchStaffUseCase } from "../domain/usecases/NotifyWatchStaffUseCase"; -import { - getOutbreakKey, - mapTrackedEntityAttributesToAlertOptions, -} from "../data/repositories/utils/AlertOutbreakMapper"; +import { mapTrackedEntityAttributesToAlertOptions } from "../data/repositories/utils/AlertOutbreakMapper"; import { AlertSyncDataStoreRepository } from "../data/repositories/AlertSyncDataStoreRepository"; import { getNotificationOptionsFromTrackedEntity } from "../data/repositories/utils/NotificationMapper"; import { AlertData } from "../domain/entities/alert/AlertData"; @@ -33,6 +30,7 @@ import { Alert } from "../domain/entities/alert/Alert"; import { AlertOptions } from "../domain/repositories/AlertRepository"; import { Option } from "../domain/entities/Ref"; import { ConfigurationsD2Repository } from "../data/repositories/ConfigurationsD2Repository"; +import { getOutbreakKey } from "../domain/entities/AlertsAndCaseForCasesData"; //TO DO : Fetch from metadata on app load const RTSL_ZEBRA_DISEASE_TEA_ID = "jLvbkuvPdZ6"; diff --git a/src/utils/tests.tsx b/src/utils/tests.tsx index e94989b2..df4baef9 100644 --- a/src/utils/tests.tsx +++ b/src/utils/tests.tsx @@ -17,7 +17,6 @@ export function getTestContext() { currentUser: createAdminUser(), compositionRoot: getTestCompositionRoot(), api: {} as D2Api, - orgUnits: [], isDev: true, configurations: { selectableOptions: { @@ -29,6 +28,7 @@ export function getTestContext() { notificationSources: [], incidentStatus: [], incidentManagers: [], + casesDataSource: [], }, riskAssessmentGradingConfigurations: { populationAtRisk: [], @@ -67,6 +67,7 @@ export function getTestContext() { incidentManagers: [], responseOfficers: [], }, + orgUnits: [], }, }; diff --git a/src/webapp/components/form/FieldWidget.tsx b/src/webapp/components/form/FieldWidget.tsx index bfdcdfcf..636e63e5 100644 --- a/src/webapp/components/form/FieldWidget.tsx +++ b/src/webapp/components/form/FieldWidget.tsx @@ -1,6 +1,5 @@ import React, { useCallback } from "react"; -import i18n from "../../../utils/i18n"; import { TextInput } from "../text-input/TextInput"; import { UserSelector } from "../user-selector/UserSelector"; import { MultipleSelector } from "../selector/MultipleSelector"; @@ -9,7 +8,8 @@ import { RadioButtonsGroup } from "../radio-buttons-group/RadioButtonsGroup"; import { TextArea } from "../text-input/TextArea"; import { DatePicker } from "../date-picker/DatePicker"; import { Checkbox } from "../checkbox/Checkbox"; -import { FormFieldState, updateFieldState } from "./FormFieldsState"; +import { FormFieldState, updateFieldState, SheetData } from "./FormFieldsState"; +import { ImportFile } from "../import-file/ImportFile"; export type FieldWidgetProps = { onChange: (updatedField: FormFieldState) => void; @@ -22,8 +22,8 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E const { field, onChange, disabled = false, errorLabels } = props; const notifyChange = useCallback( - (newValue: FormFieldState["value"]) => { - onChange(updateFieldState(field, newValue)); + (newValue: FormFieldState["value"], sheetData?: SheetData) => { + onChange(updateFieldState(field, newValue, sheetData)); }, [field, onChange] ); @@ -35,11 +35,7 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E helperText: field.helperText, errorText: field.errors ? field.errors - .map(error => - errorLabels && errorLabels[error] - ? errorLabels[error] - : i18n.t("There is an error in this field") - ) + .map(error => (errorLabels && errorLabels[error] ? errorLabels[error] : error)) .join("\n") : "", error: field.errors && field.errors.length > 0, @@ -100,5 +96,16 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E /> ); } + + case "file": { + return ( + + ); + } } }); diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index 2b11d789..a3b6d9d2 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -5,8 +5,17 @@ import { Option } from "../utils/option"; import { ValidationError, ValidationErrorKey } from "../../../domain/entities/ValidationError"; import { FormSectionState } from "./FormSectionsState"; import { Rule } from "../../../domain/entities/Rule"; - -export type FieldType = "text" | "boolean" | "select" | "radio" | "date" | "user" | "addNew"; +import { Id } from "../../../domain/entities/Ref"; + +export type FieldType = + | "text" + | "boolean" + | "select" + | "radio" + | "date" + | "user" + | "addNew" + | "file"; type FormFieldStateBase = { id: string; @@ -57,6 +66,18 @@ export type FormAvatarFieldState = FormFieldStateBase> & { options: User[]; }; +export type Row = Record; +export type SheetData = { + headers: string[]; + rows: Row[]; +}; +export type FormFileFieldState = FormFieldStateBase> & { + type: "file"; + data: Maybe; + fileId: Maybe; + fileTemplate: Maybe; +}; + export type AddNewFieldState = FormFieldStateBase & { type: "addNew"; }; @@ -67,7 +88,8 @@ export type FormFieldState = | FormMultipleOptionsFieldState | FormBooleanFieldState | FormDateFieldState - | FormAvatarFieldState; + | FormAvatarFieldState + | FormFileFieldState; // HELPERS: @@ -105,6 +127,20 @@ export function getMultipleOptionsFieldValue(id: string, allFields: FormFieldSta return getFieldValueById(id, allFields) || []; } +export function getFileFieldValue(id: string, allFields: FormFieldState[]): Maybe { + return getFieldValueById(id, allFields); +} + +export function getFieldFileDataById(id: string, allFields: FormFieldState[]): Maybe { + const field = allFields.find(field => field.id === id && field.type === "file"); + return field?.type === "file" ? field.data : undefined; +} + +export function getFieldFileIdById(id: string, allFields: FormFieldState[]): Maybe { + const field = allFields.find(field => field.id === id && field.type === "file"); + return field?.type === "file" ? field.fileId : undefined; +} + export function getFieldValueById( id: string, fields: FormFieldState[] @@ -137,6 +173,8 @@ export function getFieldWithEmptyValue(field: FormFieldState): FormFieldState { return { ...field, value: null }; case "user": return { ...field, value: undefined }; + case "file": + return { ...field, value: undefined, data: undefined, fileId: undefined }; } } @@ -152,18 +190,21 @@ export function updateFields( fieldValidationErrors?: ValidationError[] ): FormFieldState[] { return formFields.map(field => { - const errors = - fieldValidationErrors?.find(error => error.property === field.id)?.errors || []; + const validationError = fieldValidationErrors?.find(error => error.property === field.id); + const errors = validationError?.errors || []; + const errorsInFile = validationError?.errorsInFile + ? Object.values(validationError.errorsInFile).filter(error => error !== undefined) + : []; if (field.id === updatedField.id) { return { ...updatedField, - errors: errors, + errors: [...errors, ...errorsInFile], }; } else { return updatedField.updateAllStateWithValidationErrors ? { ...field, - errors: errors, + errors: [...errors, ...errorsInFile], } : field; } @@ -172,9 +213,19 @@ export function updateFields( export function updateFieldState( fieldToUpdate: F, - newValue: F["value"] + newValue: F["value"], + sheetData?: SheetData ): F { - return { ...fieldToUpdate, value: newValue }; + if (fieldToUpdate.type === "file") { + return { + ...fieldToUpdate, + value: newValue, + data: sheetData, + fileId: undefined, // If a new file is uploaded, the fileId should be undefined + }; + } else { + return { ...fieldToUpdate, value: newValue }; + } } // VALIDATIONS: @@ -210,7 +261,11 @@ export function validateField( // RULES: export function hideFieldsAndSetToEmpty(fields: FormFieldState[]): FormFieldState[] { - return fields.map(field => ({ ...getFieldWithEmptyValue(field), isVisible: false })); + return fields.map(field => ({ + ...getFieldWithEmptyValue(field), + isVisible: false, + errors: [], + })); } export function applyRulesInUpdatedField( diff --git a/src/webapp/components/map/MapSection.tsx b/src/webapp/components/map/MapSection.tsx index 8c966ece..f5e9c3d1 100644 --- a/src/webapp/components/map/MapSection.tsx +++ b/src/webapp/components/map/MapSection.tsx @@ -27,12 +27,15 @@ export const MapSection: React.FC = React.memo(props => { eventDiseaseCode, eventHazardCode, } = props; - const { orgUnits } = useAppContext(); + const { configurations } = useAppContext(); const snackbar = useSnackbar(); const allProvincesIds = useMemo( - () => orgUnits.filter(orgUnit => orgUnit.level === "Province").map(orgUnit => orgUnit.id), - [orgUnits] + () => + configurations.orgUnits + .filter(orgUnit => orgUnit.level === "Province") + .map(orgUnit => orgUnit.id), + [configurations.orgUnits] ); const { mapConfigState } = useMap({ diff --git a/src/webapp/contexts/app-context.ts b/src/webapp/contexts/app-context.ts index 8c226c8b..6c696cd2 100644 --- a/src/webapp/contexts/app-context.ts +++ b/src/webapp/contexts/app-context.ts @@ -2,7 +2,6 @@ import React, { useContext } from "react"; import { CompositionRoot } from "../../CompositionRoot"; import { User } from "../../domain/entities/User"; import { D2Api } from "../../types/d2-api"; -import { OrgUnit } from "../../domain/entities/OrgUnit"; import { Configurations } from "../../domain/entities/AppConfigurations"; export interface AppContextState { @@ -10,7 +9,6 @@ export interface AppContextState { isDev: boolean; currentUser: User; compositionRoot: CompositionRoot; - orgUnits: OrgUnit[]; configurations: Configurations; } diff --git a/src/webapp/pages/app/App.tsx b/src/webapp/pages/app/App.tsx index 4d39c89a..fdb51904 100644 --- a/src/webapp/pages/app/App.tsx +++ b/src/webapp/pages/app/App.tsx @@ -35,7 +35,6 @@ function App(props: AppProps) { const isShareButtonVisible = appConfig.appearance.showShareButton; const currentUser = await compositionRoot.users.getCurrent.execute().toPromise(); if (!currentUser) throw new Error("User not logged in"); - const orgUnits = await compositionRoot.orgUnits.getAll.execute().toPromise(); const configurations = await compositionRoot.diseaseOutbreakEvent.getConfigurations .execute() @@ -47,7 +46,6 @@ function App(props: AppProps) { compositionRoot, isDev, api, - orgUnits, configurations, }); setShowShareButton(isShareButtonVisible); diff --git a/src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts b/src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts new file mode 100644 index 00000000..e546b333 --- /dev/null +++ b/src/webapp/pages/form-page/disease-outbreak-event/CaseDataFileFieldHelper.ts @@ -0,0 +1,155 @@ +import _ from "../../../../domain/entities/generic/Collection"; + +import { CaseData } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { ValidationError, ValidationErrorKey } from "../../../../domain/entities/ValidationError"; +import { FormFileFieldState, SheetData } from "../../../components/form/FormFieldsState"; +import { doesColumnExist, formatDateToDateString } from "../utils/FileHelper"; +import { diseaseOutbreakEventFieldIds } from "./mapDiseaseOutbreakEventToInitialFormState"; +import { OrgUnit } from "../../../../domain/entities/OrgUnit"; +import { Username } from "../../../../domain/entities/User"; + +const REQUIRED_COLUMN_HEADERS = [ + "ORG UNIT", + "DATE(YYYY-MM-DD)", + "SUSPECTED", + "PROBABLE", + "CONFIRMED", + "DEATHS", +]; + +const file_headers_missing: ValidationErrorKey = "file_headers_missing"; +const file_dates_missing: ValidationErrorKey = "file_dates_missing"; +const file_dates_not_unique: ValidationErrorKey = "file_dates_not_unique"; +const file_data_not_number: ValidationErrorKey = "file_data_not_number"; +const file_org_units_incorrect: ValidationErrorKey = "file_org_units_incorrect"; + +export function validateCaseSheetData( + updatedField: FormFileFieldState, + orgUnits: OrgUnit[] +): ValidationError { + if (!updatedField.value || !updatedField.data?.headers || !updatedField.data?.rows) + return { + property: diseaseOutbreakEventFieldIds.casesDataFile, + value: updatedField.value, + errors: ["file_missing"], + }; + + if (updatedField.data.headers.length === 0 || updatedField.data.rows.length === 0) { + return { + property: diseaseOutbreakEventFieldIds.casesDataFile, + value: updatedField.value, + errors: ["file_empty"], + }; + } + + const casesDataHeadersNotPresent = REQUIRED_COLUMN_HEADERS.filter( + header => !doesColumnExist(updatedField?.data?.headers || [], header) + ); + + const allDatesInColumn = updatedField.data.rows.map(row => + row["DATE(YYYY-MM-DD)"] ? formatDateToDateString(row["DATE(YYYY-MM-DD)"]) : undefined + ); + + const allOrgUnitIds = orgUnits.map(orgUnit => orgUnit.id); + + const lineWithErrors = updatedField.data.rows.reduce( + (errors, row, index): Partial> => { + const orgUnitIncorrect = + !row["ORG UNIT"] || !allOrgUnitIds.includes(row["ORG UNIT"] || ""); + + const dateString = row["DATE(YYYY-MM-DD)"] + ? formatDateToDateString(row["DATE(YYYY-MM-DD)"]) + : undefined; + + const dateNotPresent = !dateString; + const repeatedDate = dateString && allDatesInColumn.indexOf(dateString) !== index; + + const suspectedNotPresent = isNaN(Number(row["SUSPECTED"])); + const probableNotPresent = isNaN(Number(row["PROBABLE"])); + const confirmedNotPresent = isNaN(Number(row["CONFIRMED"])); + const deathsNotPresent = isNaN(Number(row["DEATHS"])); + + return { + [file_org_units_incorrect]: orgUnitIncorrect + ? [...(errors[file_org_units_incorrect] || []), index + 2] + : errors[file_org_units_incorrect], + [file_dates_missing]: dateNotPresent + ? [...(errors[file_dates_missing] || []), index + 2] + : errors[file_dates_missing], + [file_dates_not_unique]: repeatedDate + ? [...(errors[file_dates_not_unique] || []), index + 2] + : errors[file_dates_not_unique], + [file_data_not_number]: + suspectedNotPresent || + probableNotPresent || + confirmedNotPresent || + deathsNotPresent + ? [...(errors[file_data_not_number] || []), index + 2] + : errors[file_data_not_number], + }; + }, + { + [file_org_units_incorrect]: [], + [file_dates_missing]: [], + [file_dates_not_unique]: [], + [file_data_not_number]: [], + } as Partial> + ); + + return { + property: diseaseOutbreakEventFieldIds.casesDataFile, + value: updatedField.value, + errors: [], + errorsInFile: { + [file_headers_missing]: casesDataHeadersNotPresent.length + ? `${casesDataHeadersNotPresent.join( + ", " + )} headers in file are missing. Correct headers are: DATE(YYYY-MM-DD), SUSPECTED, PROBABLE, CONFIRMED, DEATHS` + : undefined, + [file_org_units_incorrect]: lineWithErrors[file_org_units_incorrect]?.length + ? `Org unit id is incorrect in row(s): ${lineWithErrors[ + file_org_units_incorrect + ]?.join()}` + : undefined, + [file_dates_missing]: lineWithErrors[file_dates_missing]?.length + ? `Date is missing in row(s): ${lineWithErrors[file_dates_missing]?.join()}` + : undefined, + [file_dates_not_unique]: lineWithErrors[file_dates_not_unique]?.length + ? `Date is repeated in row(s): ${lineWithErrors[file_dates_not_unique]?.join()}` + : undefined, + [file_data_not_number]: lineWithErrors[file_data_not_number]?.length + ? `Data is not a number in row(s): ${lineWithErrors[file_data_not_number]?.join()}` + : undefined, + }, + }; +} + +export function getCaseDataFromField( + uploadedCasesSheetData: SheetData, + currentUsername: Username +): CaseData[] { + if (!uploadedCasesSheetData?.headers || !uploadedCasesSheetData?.rows) { + throw new Error("Case data file is missing"); + } + const casesData: CaseData[] = uploadedCasesSheetData.rows + .map(row => { + const dateString = row["DATE(YYYY-MM-DD)"] + ? formatDateToDateString(row["DATE(YYYY-MM-DD)"]) + : undefined; + + if (dateString && row["ORG UNIT"]) { + return { + updatedBy: currentUsername, + reportDate: dateString, + orgUnit: row["ORG UNIT"], + suspectedCases: Number(row["SUSPECTED"]), + probableCases: Number(row["PROBABLE"]), + confirmedCases: Number(row["CONFIRMED"]), + deaths: Number(row["DEATHS"]), + }; + } + }) + .filter((caseData): caseData is CaseData => caseData !== undefined); + + return casesData; +} diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts index 999e73c4..2ddd3c4d 100644 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts +++ b/src/webapp/pages/form-page/disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState.ts @@ -1,6 +1,9 @@ import i18n from "@eyeseetea/d2-ui-components/locales"; import { DiseaseOutbreakEventFormData } from "../../../../domain/entities/ConfigurableForm"; -import { DataSource } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DataSource, +} from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { TeamMember } from "../../../../domain/entities/incident-management-team/TeamMember"; import { getFieldIdFromIdsDictionary } from "../../../components/form/FormFieldsState"; import { FormSectionState } from "../../../components/form/FormSectionsState"; @@ -15,6 +18,8 @@ import { export const diseaseOutbreakEventFieldIds = { name: "name", + casesDataSource: "casesDataSource", + casesDataFile: "casesDataFile", dataSource: "dataSource", hazardType: "hazardType", mainSyndromeCode: "mainSyndromeCode", @@ -61,6 +66,8 @@ export function mapTeamMemberToUser(teamMember: TeamMember): User { } type MainSectionKeys = | "name" + | "casesDataSource" + | "casesDataFile" | "dataSource" | "hazardType" | "mainSyndrome" @@ -90,7 +97,13 @@ export function mapDiseaseOutbreakEventToInitialFormState( editMode: boolean, existingEventTrackerTypes: (DiseaseNames | HazardNames)[] ): FormState { - const { entity: diseaseOutbreakEvent, options } = diseaseOutbreakEventWithOptions; + const { + entity: diseaseOutbreakEvent, + options, + caseDataFileTemplete, + uploadedCasesDataFile, + uploadedCasesDataFileId, + } = diseaseOutbreakEventWithOptions; const { dataSources, incidentManagers, @@ -99,6 +112,7 @@ export function mapDiseaseOutbreakEventToInitialFormState( suspectedDiseases, notificationSources, incidentStatus, + casesDataSource, } = options; //If An Event Tracker has already been created for a given suspected disease or harzd type, @@ -112,6 +126,7 @@ export function mapDiseaseOutbreakEventToInitialFormState( const teamMemberOptions: User[] = incidentManagers.map(tm => mapTeamMemberToUser(tm)); const dataSourcesOptions: PresentationOption[] = mapToPresentationOptions(dataSources); + const casesDataSourceOptions: PresentationOption[] = mapToPresentationOptions(casesDataSource); const hazardTypesOptions: PresentationOption[] = mapToPresentationOptions(filteredHazardTypes); const mainSyndromesOptions: PresentationOption[] = mapToPresentationOptions(mainSyndromes); const suspectedDiseasesOptions: PresentationOption[] = @@ -124,6 +139,9 @@ export function mapDiseaseOutbreakEventToInitialFormState( diseaseOutbreakEvent?.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_EBS; const isIBSDataSource = diseaseOutbreakEvent?.dataSource === DataSource.RTSL_ZEB_OS_DATA_SOURCE_IBS; + const isCasesDataUserDefined = + diseaseOutbreakEvent?.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; const fromIdsDictionary = (key: keyof typeof diseaseOutbreakEventFieldIds) => getFieldIdFromIdsDictionary(key, diseaseOutbreakEventFieldIds); @@ -373,6 +391,54 @@ export function mapDiseaseOutbreakEventToInitialFormState( }, ], }, + casesDataSource: { + title: "Cases Data Source", + id: "casesDataSource_section", + isVisible: true, + required: true, + fields: [ + { + id: fromIdsDictionary("casesDataSource"), + placeholder: "Select the cases data source", + isVisible: true, + errors: [], + type: "select", + multiple: false, + options: casesDataSourceOptions, + value: diseaseOutbreakEvent?.casesDataSource || "", + width: "300px", + required: true, + showIsRequired: false, + disabled: editMode && isCasesDataUserDefined, + }, + ], + }, + casesDataFile: { + title: "Cases Data File", + id: "casesDataFile_section", + isVisible: isCasesDataUserDefined, + required: true, + fields: [ + { + id: fromIdsDictionary("casesDataFile"), + isVisible: isCasesDataUserDefined, + errors: [], + type: "file", + value: isCasesDataUserDefined ? uploadedCasesDataFile : undefined, + required: true, + showIsRequired: false, + data: undefined, + fileId: isCasesDataUserDefined ? uploadedCasesDataFileId : undefined, + fileTemplate: caseDataFileTemplete, + helperText: + editMode && isCasesDataUserDefined + ? i18n.t( + "In order to add or replace cases, you need to download the current file and add the new ones." + ) + : i18n.t("Please, download the template and add the required data."), + }, + ], + }, dataSource: { title: "Event Source", id: "dataSource_section", @@ -650,6 +716,8 @@ export function mapDiseaseOutbreakEventToInitialFormState( isValid: false, sections: [ mainSections.name, + mainSections.casesDataSource, + mainSections.casesDataFile, mainSections.dataSource, mainSections.hazardType, mainSections.mainSyndrome, diff --git a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts b/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts deleted file mode 100644 index ea0f52b1..00000000 --- a/src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEventData.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - dataSourceMap, - getHazardTypeFromString, - incidentStatusMap, -} from "../../../../data/repositories/consts/DiseaseOutbreakConstants"; -import { DiseaseOutbreakEventFormData } from "../../../../domain/entities/ConfigurableForm"; -import { DiseaseOutbreakEventBaseAttrs } from "../../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -import { - FormFieldState, - getAllFieldsFromSections, - getBooleanFieldValue, - getDateFieldValue, - getMultipleOptionsFieldValue, - getStringFieldValue, -} from "../../../components/form/FormFieldsState"; -import { FormState } from "../../../components/form/FormState"; -import { diseaseOutbreakEventFieldIds } from "./mapDiseaseOutbreakEventToInitialFormState"; - -type DateFieldIdsToValidate = - | "emergedDate" - | "detectedDate" - | "notifiedDate" - | "initiateInvestigation" - | "conductEpidemiologicalAnalysis" - | "laboratoryConfirmation"; - -export function mapFormStateToDiseaseOutbreakEventData( - formState: FormState, - currentUserName: string, - configurableDiseaseOutbreakEventForm: DiseaseOutbreakEventFormData -): DiseaseOutbreakEventBaseAttrs { - const diseaseOutbreakEvent = configurableDiseaseOutbreakEventForm.entity; - const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); - - const dataSource = - dataSourceMap[getStringFieldValue(diseaseOutbreakEventFieldIds.dataSource, allFields)]; - - const incidentStatus = - incidentStatusMap[ - getStringFieldValue(diseaseOutbreakEventFieldIds.incidentStatus, allFields) - ]; - - if (!dataSource || !incidentStatus) - throw new Error(`Data source or incident status not valid.`); - - const dateValuesByFieldId = getValidDateValuesByFieldIdFromFields(allFields); - - const diseaseOutbreakEventEditableData = { - name: getStringFieldValue(diseaseOutbreakEventFieldIds.name, allFields), - dataSource: dataSource, - hazardType: getHazardTypeFromString( - getStringFieldValue(diseaseOutbreakEventFieldIds.hazardType, allFields) - ), - mainSyndromeCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.mainSyndromeCode, - allFields - ), - suspectedDiseaseCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.suspectedDiseaseCode, - allFields - ), - notificationSourceCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.notificationSourceCode, - allFields - ), - areasAffectedProvinceIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedProvinceIds, - allFields - ), - areasAffectedDistrictIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedDistrictIds, - allFields - ), - incidentStatus: incidentStatus, - emerged: { - date: dateValuesByFieldId.emergedDate, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.emergedNarrative, - allFields - ), - }, - detected: { - date: dateValuesByFieldId.detectedDate, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.detectedNarrative, - allFields - ), - }, - notified: { - date: dateValuesByFieldId.notifiedDate, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.notifiedNarrative, - allFields - ), - }, - earlyResponseActions: { - initiateInvestigation: dateValuesByFieldId.initiateInvestigation, - conductEpidemiologicalAnalysis: dateValuesByFieldId.conductEpidemiologicalAnalysis, - laboratoryConfirmation: dateValuesByFieldId.laboratoryConfirmation, - appropriateCaseManagement: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementNA, - allFields - ), - }, - initiatePublicHealthCounterMeasures: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresNA, - allFields - ), - }, - initiateRiskCommunication: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationNA, - allFields - ), - }, - establishCoordination: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationNa, - allFields - ), - }, - responseNarrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.responseNarrative, - allFields - ), - }, - incidentManagerName: getStringFieldValue( - diseaseOutbreakEventFieldIds.incidentManagerName, - allFields - ), - notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), - }; - - const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { - id: diseaseOutbreakEvent?.id || "", - status: diseaseOutbreakEvent?.status || "ACTIVE", - created: diseaseOutbreakEvent?.created, - lastUpdated: diseaseOutbreakEvent?.lastUpdated, - createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, - ...diseaseOutbreakEventEditableData, - }; - - return diseaseOutbreakEventBase; -} - -function getValidDateValuesByFieldIdFromFields( - allFields: FormFieldState[] -): Record { - const getFromAllFields = (fieldId: keyof typeof diseaseOutbreakEventFieldIds): Date => { - const maybeDate = getDateFieldValue(fieldId, allFields); - - if (maybeDate === null) { - throw new Error(`Invalid date value.`); - } else { - return maybeDate; - } - }; - - return { - emergedDate: getFromAllFields(diseaseOutbreakEventFieldIds.emergedDate), - detectedDate: getFromAllFields(diseaseOutbreakEventFieldIds.detectedDate), - notifiedDate: getFromAllFields(diseaseOutbreakEventFieldIds.notifiedDate), - initiateInvestigation: getFromAllFields(diseaseOutbreakEventFieldIds.initiateInvestigation), - conductEpidemiologicalAnalysis: getFromAllFields( - diseaseOutbreakEventFieldIds.conductEpidemiologicalAnalysis - ), - laboratoryConfirmation: getFromAllFields( - diseaseOutbreakEventFieldIds.laboratoryConfirmation - ), - }; -} diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index b738a802..d55be9f5 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -1,5 +1,7 @@ import { + CasesDataSource, DataSource, + DiseaseOutbreakEvent, DiseaseOutbreakEventBaseAttrs, HazardType, NationalIncidentStatus, @@ -11,6 +13,9 @@ import { getAllFieldsFromSections, getBooleanFieldValue, getDateFieldValue, + getFieldFileDataById, + getFieldFileIdById, + getFileFieldValue, getMultipleOptionsFieldValue, getStringFieldValue, } from "../../components/form/FormFieldsState"; @@ -50,6 +55,7 @@ import { import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; +import { getCaseDataFromField } from "./disease-outbreak-event/CaseDataFileFieldHelper"; export function mapFormStateToEntityData( formState: FormState, @@ -63,9 +69,22 @@ export function mapFormStateToEntityData( currentUserName, formData.entity ); + + const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); + const uploadedCasesDataFileValue = getFileFieldValue( + diseaseOutbreakEventFieldIds.casesDataFile, + allFields + ); + const uploadedCasesDataFileId = getFieldFileIdById( + diseaseOutbreakEventFieldIds.casesDataFile, + allFields + ); + const diseaseForm: DiseaseOutbreakEventFormData = { ...formData, entity: dieaseEntity, + uploadedCasesDataFile: uploadedCasesDataFileValue, + uploadedCasesDataFileId: uploadedCasesDataFileId, }; return diseaseForm; } @@ -134,8 +153,8 @@ export function mapFormStateToEntityData( function mapFormStateToDiseaseOutbreakEvent( formState: FormState, currentUserName: string, - diseaseOutbreakEvent: Maybe -): DiseaseOutbreakEventBaseAttrs { + diseaseOutbreakEvent: Maybe +): DiseaseOutbreakEvent { const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); const diseaseOutbreakEventEditableData = { @@ -256,8 +275,26 @@ function mapFormStateToDiseaseOutbreakEvent( allFields ), notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), + casesDataSource: getStringFieldValue( + diseaseOutbreakEventFieldIds.casesDataSource, + allFields + ) as CasesDataSource, }; + const isCasesDataUserDefined = + diseaseOutbreakEventEditableData.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + const uploadedCasesSheetData = isCasesDataUserDefined + ? getFieldFileDataById(diseaseOutbreakEventFieldIds.casesDataFile, allFields) + : undefined; + + const hasCasesDataChange = isCasesDataUserDefined && uploadedCasesSheetData; + + const casesData = hasCasesDataChange + ? getCaseDataFromField(uploadedCasesSheetData, currentUserName) + : diseaseOutbreakEvent?.uploadedCasesData; + const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { id: diseaseOutbreakEvent?.id || "", status: diseaseOutbreakEvent?.status || "ACTIVE", @@ -266,8 +303,24 @@ function mapFormStateToDiseaseOutbreakEvent( createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, ...diseaseOutbreakEventEditableData, }; + const newDiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + uploadedCasesData: undefined, + + // NOTICE: Not needed but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); - return diseaseOutbreakEventBase; + return casesData + ? newDiseaseOutbreakEvent.addUploadedCasesData(casesData) + : newDiseaseOutbreakEvent; } function mapFormStateToRiskAssessmentGrading(formState: FormState): RiskAssessmentGrading { diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index d237f8c7..1136b248 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -6,7 +6,7 @@ import { Id } from "../../../domain/entities/Ref"; import { FormState } from "../../components/form/FormState"; import { RouteName, useRoutes } from "../../hooks/useRoutes"; import { mapFormStateToEntityData } from "./mapFormStateToEntityData"; -import { updateAndValidateFormState } from "./utils/updateDiseaseOutbreakEventFormState"; +import { updateAndValidateFormState } from "./utils/updateAndValidateFormState"; import { FormFieldState } from "../../components/form/FormFieldsState"; import { FormType } from "./FormPage"; import { ConfigurableForm, FormLables } from "../../../domain/entities/ConfigurableForm"; @@ -242,7 +242,7 @@ export function useForm(formType: FormType, id?: Id): State { configurableForm ); - compositionRoot.save.execute(formData, configurations).run( + compositionRoot.save.execute(formData, configurations, !!id).run( diseaseOutbreakEventId => { setIsLoading(false); @@ -345,6 +345,7 @@ export function useForm(formType: FormType, id?: Id): State { configurableForm, currentUser.username, compositionRoot, + id, currentEventTracker?.id, goTo, ]); diff --git a/src/webapp/pages/form-page/utils/FileHelper.ts b/src/webapp/pages/form-page/utils/FileHelper.ts new file mode 100644 index 00000000..89dd7390 --- /dev/null +++ b/src/webapp/pages/form-page/utils/FileHelper.ts @@ -0,0 +1,39 @@ +import * as XLSX from "xlsx"; + +import { Row, SheetData } from "../../../components/form/FormFieldsState"; + +export async function readFile(file: File): Promise { + const workbook = XLSX.read(await file.arrayBuffer(), { cellDates: true }); + + return Object.values(workbook.Sheets).map((worksheet): SheetData => { + const headers = + XLSX.utils.sheet_to_json(worksheet, { + header: 1, + defval: "", + })[0] || []; + const rows = XLSX.utils.sheet_to_json(worksheet, { + raw: true, + skipHidden: false, + }); + + return { + headers, + rows, + }; + }); +} + +export function doesColumnExist(header: string[], column: string): boolean { + return header.find(value => value === column) !== undefined; +} + +export function formatDateToDateString(date: Date | string): string | undefined { + if (date instanceof Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + } else { + return date; + } +} diff --git a/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts b/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts similarity index 89% rename from src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts rename to src/webapp/pages/form-page/utils/updateAndValidateFormState.ts index 434e8835..bccab50c 100644 --- a/src/webapp/pages/form-page/utils/updateDiseaseOutbreakEventFormState.ts +++ b/src/webapp/pages/form-page/utils/updateAndValidateFormState.ts @@ -12,6 +12,7 @@ import { updateFormStateWithFieldErrors, validateForm, } from "../../../components/form/FormState"; +import { validateCaseSheetData } from "../disease-outbreak-event/CaseDataFileFieldHelper"; import { applyRulesInFormState } from "./applyRulesInFormState"; export function updateAndValidateFormState( @@ -53,11 +54,16 @@ function validateFormState( ): ValidationError[] { const formValidationErrors = validateForm(updatedForm, updatedField); let entityValidationErrors: ValidationError[] = []; + let sheetDataValidationErrors: ValidationError[] = []; switch (configurableForm.type) { case "disease-outbreak-event": { if (configurableForm.entity) entityValidationErrors = DiseaseOutbreakEvent.validate(configurableForm.entity); + sheetDataValidationErrors = + updatedField.type === "file" + ? [validateCaseSheetData(updatedField, configurableForm.orgUnits)] + : []; break; } case "risk-assessment-grading": @@ -90,5 +96,5 @@ function validateFormState( } } - return [...formValidationErrors, ...entityValidationErrors]; + return [...formValidationErrors, ...entityValidationErrors, ...sheetDataValidationErrors]; } From 643c25c6fa8949b3a6efb475f7efeffb1d5c3962 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 21 Nov 2024 09:31:56 +0100 Subject: [PATCH 04/21] Update test --- src/webapp/components/form/__tests__/Form.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/webapp/components/form/__tests__/Form.spec.tsx b/src/webapp/components/form/__tests__/Form.spec.tsx index 95ae764a..9765ee1c 100644 --- a/src/webapp/components/form/__tests__/Form.spec.tsx +++ b/src/webapp/components/form/__tests__/Form.spec.tsx @@ -233,7 +233,7 @@ function givenFormProps(): FormProps { label: "Field text visible not required", isVisible: true, helperText: "text field helper text not required", - errors: ["this_is_an_error"], + errors: ["There is an error in this field"], type: "text", value: "text value not required", multiline: false, From ee21c49d2a371b20c9fbeca7dffc6c067196a4e0 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Fri, 29 Nov 2024 08:41:39 +0100 Subject: [PATCH 05/21] Get from datastore the indicators for alerts program source or cases program source depending on the config in disease outbreak event --- .../repositories/ChartConfigD2Repository.ts | 15 +- .../repositories/MapConfigD2Repository.ts | 8 +- .../PerformanceOverviewD2Repository.ts | 788 +++++++++++------- .../getProgramIndicatorsFromDatastore.ts | 9 +- .../consts/PerformanceOverviewConstants.ts | 57 +- .../test/ChartConfigTestRepository.ts | 5 +- .../test/MapConfigTestRepository.ts | 3 +- .../utils/DiseaseOutbreakMapper.ts | 6 +- .../DiseaseOutbreakEvent.ts | 2 +- .../PerformanceOverviewMetrics.ts | 1 + .../repositories/ChartConfigRepository.ts | 5 +- .../repositories/MapConfigRepository.ts | 3 +- .../PerformanceOverviewRepository.ts | 10 +- .../usecases/GetChartConfigByTypeUseCase.ts | 20 +- src/domain/usecases/GetMapConfigUseCase.ts | 5 +- .../usecases/GetOverviewCardsUseCase.ts | 8 +- src/webapp/components/chart/Chart.tsx | 6 +- src/webapp/components/chart/useChart.ts | 11 +- src/webapp/components/map/MapSection.tsx | 4 + src/webapp/components/map/useMap.ts | 21 +- .../pages/event-tracker/EventTrackerPage.tsx | 3 + .../pages/event-tracker/useOverviewCards.ts | 6 +- 22 files changed, 610 insertions(+), 386 deletions(-) diff --git a/src/data/repositories/ChartConfigD2Repository.ts b/src/data/repositories/ChartConfigD2Repository.ts index 17ac2418..5d3cb2be 100644 --- a/src/data/repositories/ChartConfigD2Repository.ts +++ b/src/data/repositories/ChartConfigD2Repository.ts @@ -2,12 +2,14 @@ import { DataStoreClient } from "../DataStoreClient"; import { FutureData } from "../api-futures"; import { ChartConfigRepository } from "../../domain/repositories/ChartConfigRepository"; import { Id } from "../../domain/entities/Ref"; +import { CasesDataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type ChartConfig = { key: string; casesId: Id; deathsId: Id; riskAssessmentHistoryId: Id; + casesDataSource: CasesDataSource; }; const chartConfigDatastoreKey = "charts-config"; @@ -15,24 +17,29 @@ const chartConfigDatastoreKey = "charts-config"; export class ChartConfigD2Repository implements ChartConfigRepository { constructor(private dataStoreClient: DataStoreClient) {} - public getCases(chartKey: string): FutureData { + public getCases(chartKey: string, casesDataSource: CasesDataSource): FutureData { return this.dataStoreClient .getObject(chartConfigDatastoreKey) .map(chartConfigs => { const currentChart = chartConfigs?.find( - chartConfig => chartConfig.key === chartKey + chartConfig => + chartConfig.key === chartKey && + chartConfig.casesDataSource === casesDataSource ); + if (currentChart) return currentChart.casesId; else throw new Error(`Chart id not found for ${chartKey}`); }); } - public getDeaths(chartKey: string): FutureData { + public getDeaths(chartKey: string, casesDataSource: CasesDataSource): FutureData { return this.dataStoreClient .getObject(chartConfigDatastoreKey) .map(chartConfigs => { const currentChart = chartConfigs?.find( - chartConfig => chartConfig.key === chartKey + chartConfig => + chartConfig.key === chartKey && + chartConfig.casesDataSource === casesDataSource ); if (currentChart) return currentChart.deathsId; else throw new Error(`Chart id not found for ${chartKey}`); diff --git a/src/data/repositories/MapConfigD2Repository.ts b/src/data/repositories/MapConfigD2Repository.ts index 94b383c4..bb96f716 100644 --- a/src/data/repositories/MapConfigD2Repository.ts +++ b/src/data/repositories/MapConfigD2Repository.ts @@ -1,3 +1,4 @@ +import { CasesDataSource } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../domain/entities/generic/Future"; import { MapKey, @@ -24,11 +25,14 @@ export class MapConfigD2Repository implements MapConfigRepository { this.dataStoreClient = new DataStoreClient(api); } - public get(mapKey: MapKey): FutureData { + public get(mapKey: MapKey, casesDataSource?: CasesDataSource): FutureData { const programIndicatorsDatastoreKey = mapKey === "dashboard" ? ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts - : ProgramIndicatorsDatastoreKey.CasesAlerts; + : casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ? ProgramIndicatorsDatastoreKey.SuspectedCasesCasesProgram + : ProgramIndicatorsDatastoreKey.SuspectedCasesAlertsProgram; + return this.dataStoreClient .getObject(MAPS_CONFIG_KEY) .flatMap(mapsConfigDatastore => { diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index c0dc874b..b212c2c8 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -6,13 +6,12 @@ import { RTSL_ZEBRA_PROGRAM_ID } from "./consts/DiseaseOutbreakConstants"; import _ from "../../domain/entities/generic/Collection"; import { Future } from "../../domain/entities/generic/Future"; import { - PERFORMANCE_METRICS_717_IDS, - IndicatorsId, - EVENT_TRACKER_717_IDS, EventTrackerCountIndicator, + PerformanceOverviewDimensions, } from "./consts/PerformanceOverviewConstants"; import moment from "moment"; import { + CasesDataSource, DiseaseOutbreakEventBaseAttrs, NationalIncidentStatus, } from "../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; @@ -25,7 +24,6 @@ import { PerformanceMetrics717, IncidentStatus, } from "../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; -import { OrgUnit } from "../../domain/entities/OrgUnit"; import { Id } from "../../domain/entities/Ref"; import { OverviewCard } from "../../domain/entities/PerformanceOverview"; import { assertOrError } from "./utils/AssertOrError"; @@ -44,9 +42,15 @@ const formatDate = (date: Date): string => { const DEFAULT_END_DATE: string = formatDate(new Date()); const DEFAULT_START_DATE = "2000-01-01"; -const EVENT_TRACKER_OVERVIEW_DATASTORE_KEY = "event-tracker-overview-ids"; -type EventTrackerOverview = { +const ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY = + "alerts-program-event-tracker-overview-ids"; +const CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY = + "cases-program-event-tracker-overview-ids"; +const PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY = "717-performance-program-indicators"; +const PERFORMANCE_OVERVIEW_DIMENSIONS_DATASTORE_KEY = "performance-overview-dimensions"; + +type EventTrackerOverviewInDataStore = { key: string; suspectedCasesId: Id; confirmedCasesId: Id; @@ -54,6 +58,10 @@ type EventTrackerOverview = { probableCasesId: Id; }; +type EventTrackerOverview = EventTrackerOverviewInDataStore & { + casesDataSource: CasesDataSource; +}; + type IdValue = { id: Id; value: string; @@ -215,266 +223,344 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos } private getEventTrackerOverviewIdsFromDatastore( - type: string + type: string, + casesDataSource: CasesDataSource ): FutureData { + const datastoreKey = + casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ? CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY + : ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY; + return this.datastore - .getObject(EVENT_TRACKER_OVERVIEW_DATASTORE_KEY) + .getObject(datastoreKey) .flatMap(nullableEventTrackerOverviewIds => { - return assertOrError( - nullableEventTrackerOverviewIds, - EVENT_TRACKER_OVERVIEW_DATASTORE_KEY - ).flatMap(eventTrackerOverviewIds => { - const currentEventTrackerOverviewId = eventTrackerOverviewIds?.find( - indicator => indicator.key === type - ); - - if (!currentEventTrackerOverviewId) - return Future.error( - new Error( - `Event Tracker Overview Ids for type ${type} not found in datastore` - ) + return assertOrError(nullableEventTrackerOverviewIds, datastoreKey).flatMap( + eventTrackerOverviewIds => { + const currentEventTrackerOverviewId = eventTrackerOverviewIds?.find( + indicator => indicator.key === type ); - return Future.success(currentEventTrackerOverviewId); - }); + + if (!currentEventTrackerOverviewId) + return Future.error( + new Error( + `Event Tracker Overview Ids for type ${type} not found in datastore` + ) + ); + return Future.success({ + ...currentEventTrackerOverviewId, + casesDataSource: casesDataSource, + }); + } + ); }); } private getAllEventTrackerOverviewIdsFromDatastore(): FutureData { - return this.datastore - .getObject(EVENT_TRACKER_OVERVIEW_DATASTORE_KEY) - .flatMap(nullableEventTrackerOverviewIds => { + return Future.joinObj({ + alertsEventTrackerOverviewIdsResponse: this.datastore.getObject< + EventTrackerOverviewInDataStore[] + >(ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY), + casesEventTrackerOverviewIdsResponse: this.datastore.getObject< + EventTrackerOverviewInDataStore[] + >(CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY), + }).flatMap( + ({ alertsEventTrackerOverviewIdsResponse, casesEventTrackerOverviewIdsResponse }) => { return assertOrError( - nullableEventTrackerOverviewIds, - EVENT_TRACKER_OVERVIEW_DATASTORE_KEY - ); - }); + alertsEventTrackerOverviewIdsResponse, + ALERTS_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY + ).flatMap(alertsEventTrackerOverviewIds => { + return assertOrError( + casesEventTrackerOverviewIdsResponse, + CASES_PROGRAM_EVENT_TRACKER_OVERVIEW_DATASTORE_KEY + ).flatMap(casesEventTrackerOverviewIds => { + return Future.success([ + ...alertsEventTrackerOverviewIds.map( + ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + }) => ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + casesDataSource: + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, + }) + ), + ...casesEventTrackerOverviewIds.map( + ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + }) => ({ + key, + suspectedCasesId, + confirmedCasesId, + deathsId, + probableCasesId, + casesDataSource: + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF, + }) + ), + ]); + }); + }); + } + ); } - getEventTrackerOverviewMetrics(type: string): FutureData { - return this.getEventTrackerOverviewIdsFromDatastore(type).flatMap(eventTrackerOverview => { - const { suspectedCasesId, probableCasesId, confirmedCasesId, deathsId } = - eventTrackerOverview; - - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(new Date().getDate() - 7); - - return Future.joinObj( - { - cumulativeSuspectedCases: this.getAnalyticsApi( - suspectedCasesId, - DEFAULT_START_DATE - ), - newSuspectedCases: this.getAnalyticsApi( - suspectedCasesId, - formatDate(sevenDaysAgo) - ), - cumulativeProbableCases: this.getAnalyticsApi( - probableCasesId, - DEFAULT_START_DATE - ), - newProbableCases: this.getAnalyticsApi( - probableCasesId, - formatDate(sevenDaysAgo) - ), - cumulativeConfirmedCases: this.getAnalyticsApi( - confirmedCasesId, - DEFAULT_START_DATE - ), - newConfirmedCases: this.getAnalyticsApi( - confirmedCasesId, - formatDate(sevenDaysAgo) - ), - cumulativeDeaths: this.getAnalyticsApi(deathsId, DEFAULT_START_DATE), - newDeaths: this.getAnalyticsApi(deathsId, formatDate(sevenDaysAgo)), - }, - { concurrency: 5 } - ).flatMap( - ({ - cumulativeSuspectedCases, - newSuspectedCases, - cumulativeProbableCases, - newProbableCases, - cumulativeConfirmedCases, - newConfirmedCases, - cumulativeDeaths, - newDeaths, - }) => { - return Future.success([ - { - name: "New Suspected Cases", - value: newSuspectedCases?.rows[0]?.[1] - ? parseInt(newSuspectedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "New Probable Cases", - value: newProbableCases?.rows[0]?.[1] - ? parseInt(newProbableCases?.rows[0]?.[1]) - : 0, - }, - { - name: "New Confirmed Cases", - value: newConfirmedCases?.rows[0]?.[1] - ? parseInt(newConfirmedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "New Deaths", - value: newDeaths?.rows[0]?.[1] ? parseInt(newDeaths?.rows[0]?.[1]) : 0, - }, - { - name: "Cumulative Suspected Cases", - value: cumulativeSuspectedCases?.rows[0]?.[1] - ? parseInt(cumulativeSuspectedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "Cumulative Probable Cases", - value: cumulativeProbableCases?.rows[0]?.[1] - ? parseInt(cumulativeProbableCases?.rows[0]?.[1]) - : 0, - }, - { - name: "Cumulative Confirmed Cases", - value: cumulativeConfirmedCases?.rows[0]?.[1] - ? parseInt(cumulativeConfirmedCases?.rows[0]?.[1]) - : 0, - }, - { - name: "Cumulative Deaths", - value: cumulativeDeaths?.rows[0]?.[1] - ? parseInt(cumulativeDeaths?.rows[0]?.[1]) - : 0, - }, - ]); - } - ); - }); + getEventTrackerOverviewMetrics( + type: string, + casesDataSource: CasesDataSource + ): FutureData { + return this.getEventTrackerOverviewIdsFromDatastore(type, casesDataSource).flatMap( + eventTrackerOverview => { + const { suspectedCasesId, probableCasesId, confirmedCasesId, deathsId } = + eventTrackerOverview; + + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(new Date().getDate() - 7); + + return Future.joinObj( + { + cumulativeSuspectedCases: this.getAnalyticsApi( + suspectedCasesId, + DEFAULT_START_DATE + ), + newSuspectedCases: this.getAnalyticsApi( + suspectedCasesId, + formatDate(sevenDaysAgo) + ), + cumulativeProbableCases: this.getAnalyticsApi( + probableCasesId, + DEFAULT_START_DATE + ), + newProbableCases: this.getAnalyticsApi( + probableCasesId, + formatDate(sevenDaysAgo) + ), + cumulativeConfirmedCases: this.getAnalyticsApi( + confirmedCasesId, + DEFAULT_START_DATE + ), + newConfirmedCases: this.getAnalyticsApi( + confirmedCasesId, + formatDate(sevenDaysAgo) + ), + cumulativeDeaths: this.getAnalyticsApi(deathsId, DEFAULT_START_DATE), + newDeaths: this.getAnalyticsApi(deathsId, formatDate(sevenDaysAgo)), + }, + { concurrency: 5 } + ).flatMap( + ({ + cumulativeSuspectedCases, + newSuspectedCases, + cumulativeProbableCases, + newProbableCases, + cumulativeConfirmedCases, + newConfirmedCases, + cumulativeDeaths, + newDeaths, + }) => { + return Future.success([ + { + name: "New Suspected Cases", + value: newSuspectedCases?.rows[0]?.[1] + ? parseInt(newSuspectedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "New Probable Cases", + value: newProbableCases?.rows[0]?.[1] + ? parseInt(newProbableCases?.rows[0]?.[1]) + : 0, + }, + { + name: "New Confirmed Cases", + value: newConfirmedCases?.rows[0]?.[1] + ? parseInt(newConfirmedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "New Deaths", + value: newDeaths?.rows[0]?.[1] + ? parseInt(newDeaths?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Suspected Cases", + value: cumulativeSuspectedCases?.rows[0]?.[1] + ? parseInt(cumulativeSuspectedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Probable Cases", + value: cumulativeProbableCases?.rows[0]?.[1] + ? parseInt(cumulativeProbableCases?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Confirmed Cases", + value: cumulativeConfirmedCases?.rows[0]?.[1] + ? parseInt(cumulativeConfirmedCases?.rows[0]?.[1]) + : 0, + }, + { + name: "Cumulative Deaths", + value: cumulativeDeaths?.rows[0]?.[1] + ? parseInt(cumulativeDeaths?.rows[0]?.[1]) + : 0, + }, + ]); + } + ); + } + ); } getPerformanceOverviewMetrics( diseaseOutbreakEvents: DiseaseOutbreakEventBaseAttrs[] ): FutureData { - return apiToFuture( - this.api.analytics.getEnrollmentsQuery({ - programId: RTSL_ZEBRA_PROGRAM_ID, - dimension: [ - IndicatorsId.suspectedDisease, - IndicatorsId.hazardType, - IndicatorsId.event, - IndicatorsId.era1, - IndicatorsId.era2, - IndicatorsId.era3, - IndicatorsId.era4, - IndicatorsId.era5, - IndicatorsId.era6, - IndicatorsId.era7, - IndicatorsId.detect7d, - IndicatorsId.notify1d, - IndicatorsId.respond7d, - ], - startDate: DEFAULT_START_DATE, - endDate: DEFAULT_END_DATE, - }) - ).flatMap(indicatorsProgramFuture => { - return this.getAllEventTrackerOverviewIdsFromDatastore().flatMap( - eventTrackerOverviews => { - const mappedIndicators = - indicatorsProgramFuture?.rows.map((row: string[]) => - this.mapRowToBaseIndicator( - row, - indicatorsProgramFuture.headers, - indicatorsProgramFuture.metaData - ) - ) || []; - - const keys = _( - diseaseOutbreakEvents.map( - diseaseOutbreak => - diseaseOutbreak.suspectedDiseaseCode || diseaseOutbreak.hazardType - ) - ) - .compact() - .uniq() - .value(); - - const eventTrackerOverviewsForKeys = eventTrackerOverviews.filter(overview => - keys.includes(overview.key) - ); - - const casesIndicatorIds = eventTrackerOverviewsForKeys.map( - overview => overview.suspectedCasesId - ); - - const deathsIndicatorIds = eventTrackerOverviewsForKeys.map( - overview => overview.deathsId - ); - - return Future.joinObj({ - allCases: this.getAnalyticsByIndicators(casesIndicatorIds), - allDeaths: this.getAnalyticsByIndicators(deathsIndicatorIds), - }).flatMap(({ allCases, allDeaths }) => { - const performanceOverviewMetrics: FutureData[] = - diseaseOutbreakEvents.map(event => { - const baseIndicator = mappedIndicators.find( - indicator => indicator.id === event.id - ); - - const key = event.hazardType || event.suspectedDiseaseCode; - if (!key) - return Future.error( - new Error( - `No hazard type or suspected disease found for event : ${event.id}` + return this.datastore + .getObject(PERFORMANCE_OVERVIEW_DIMENSIONS_DATASTORE_KEY) + .flatMap(nullablePerformanceOverviewDimensions => { + return assertOrError( + nullablePerformanceOverviewDimensions, + PERFORMANCE_OVERVIEW_DIMENSIONS_DATASTORE_KEY + ).flatMap(performanceOverviewDimensions => { + return apiToFuture( + this.api.analytics.getEnrollmentsQuery({ + programId: RTSL_ZEBRA_PROGRAM_ID, + dimension: [ + performanceOverviewDimensions.suspectedDisease, + performanceOverviewDimensions.hazardType, + performanceOverviewDimensions.event, + performanceOverviewDimensions.era1ProgramIndicator, + performanceOverviewDimensions.era2ProgramIndicator, + performanceOverviewDimensions.era3ProgramIndicator, + performanceOverviewDimensions.era4ProgramIndicator, + performanceOverviewDimensions.era5ProgramIndicator, + performanceOverviewDimensions.era6ProgramIndicator, + performanceOverviewDimensions.era7ProgramIndicator, + performanceOverviewDimensions.detect7dProgramIndicator, + performanceOverviewDimensions.notify1dProgramIndicator, + performanceOverviewDimensions.respond7dProgramIndicator, + ], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + }) + ).flatMap(indicatorsProgramFuture => { + return this.getAllEventTrackerOverviewIdsFromDatastore().flatMap( + eventTrackerOverviews => { + const mappedIndicators = + indicatorsProgramFuture?.rows.map((row: string[]) => + this.mapRowToBaseIndicator( + row, + indicatorsProgramFuture.headers, + indicatorsProgramFuture.metaData, + performanceOverviewDimensions ) - ); - const currentEventTrackerOverview = - eventTrackerOverviewsForKeys.find( - overview => overview.key === key - ); - - const currentCases = allCases.find( - caseIdValue => - caseIdValue.id === - currentEventTrackerOverview?.suspectedCasesId + ) || []; + + const keys = _( + diseaseOutbreakEvents.map( + diseaseOutbreak => + diseaseOutbreak.suspectedDiseaseCode || + diseaseOutbreak.hazardType + ) + ) + .compact() + .uniq() + .value(); + + const eventTrackerOverviewsForKeys = eventTrackerOverviews.filter( + overview => keys.includes(overview.key) ); - const currentDeaths = allDeaths.find( - death => death.id === currentEventTrackerOverview?.deathsId + const casesIndicatorIds = eventTrackerOverviewsForKeys.map( + overview => overview.suspectedCasesId ); - const duration = `${moment() - .diff(moment(event.emerged.date), "days") - .toString()}d`; - - if (!baseIndicator) { - const metrics = { - id: event.id, - event: event.name, - manager: event.incidentManagerName, - duration: duration, - nationalIncidentStatus: event.incidentStatus, - cases: currentCases?.value || "", - deaths: currentDeaths?.value || "", - } as PerformanceOverviewMetrics; - return Future.success(metrics); - } else { - const metrics = { - ...baseIndicator, - nationalIncidentStatus: event.incidentStatus, - manager: event.incidentManagerName, - duration: duration, - cases: currentCases?.value || "", - deaths: currentDeaths?.value || "", - } as PerformanceOverviewMetrics; - return Future.success(metrics); - } - }); + const deathsIndicatorIds = eventTrackerOverviewsForKeys.map( + overview => overview.deathsId + ); - return Future.sequential(performanceOverviewMetrics); + return Future.joinObj({ + allCases: this.getAnalyticsByIndicators(casesIndicatorIds), + allDeaths: this.getAnalyticsByIndicators(deathsIndicatorIds), + }).flatMap(({ allCases, allDeaths }) => { + const performanceOverviewMetrics: FutureData[] = + diseaseOutbreakEvents.map(event => { + const baseIndicator = mappedIndicators.find( + indicator => indicator.id === event.id + ); + + const key = + event.hazardType || event.suspectedDiseaseCode; + if (!key) + return Future.error( + new Error( + `No hazard type or suspected disease found for event : ${event.id}` + ) + ); + const currentEventTrackerOverview = + eventTrackerOverviewsForKeys.find( + overview => overview.key === key + ); + + const currentCases = allCases.find( + caseIdValue => + caseIdValue.id === + currentEventTrackerOverview?.suspectedCasesId + ); + + const currentDeaths = allDeaths.find( + death => + death.id === + currentEventTrackerOverview?.deathsId + ); + + const duration = `${moment() + .diff(moment(event.emerged.date), "days") + .toString()}d`; + + if (!baseIndicator) { + const metrics = { + id: event.id, + event: event.name, + manager: event.incidentManagerName, + duration: duration, + nationalIncidentStatus: event.incidentStatus, + cases: currentCases?.value || "", + deaths: currentDeaths?.value || "", + } as PerformanceOverviewMetrics; + return Future.success(metrics); + } else { + const metrics = { + ...baseIndicator, + nationalIncidentStatus: event.incidentStatus, + manager: event.incidentManagerName, + duration: duration, + cases: currentCases?.value || "", + deaths: currentDeaths?.value || "", + } as PerformanceOverviewMetrics; + return Future.success(metrics); + } + }); + + return Future.sequential(performanceOverviewMetrics); + }); + } + ); }); - } - ); - }); + }); + }); } private getAnalyticsByIndicators(ids: Id[]): FutureData { @@ -519,82 +605,174 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos } getDashboard717Performance(): FutureData { - return apiToFuture( - this.api.analytics.get({ - dimension: [`dx:${PERFORMANCE_METRICS_717_IDS.map(({ id }) => id).join(";")}`], - startDate: DEFAULT_START_DATE, - endDate: DEFAULT_END_DATE, - includeMetadataDetails: true, - }) - ).map(res => { - return this.mapIndicatorsTo717PerformanceMetrics(res.rows, PERFORMANCE_METRICS_717_IDS); - }); + return this.datastore + .getObject(PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY) + .flatMap(nullable717PerformanceProgramIndicators => { + return assertOrError( + nullable717PerformanceProgramIndicators, + PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY + ).flatMap(performance717ProgramIndicators => { + const dashboard717PerformanceIndicator = performance717ProgramIndicators.filter( + indicator => indicator.key === "dashboard" + ); + return apiToFuture( + this.api.analytics.get({ + dimension: [ + `dx:${dashboard717PerformanceIndicator + .map(({ id }) => id) + .join(";")}`, + ], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + includeMetadataDetails: true, + }) + ).map(res => { + return this.mapIndicatorsTo717PerformanceMetrics( + res.rows, + dashboard717PerformanceIndicator + ); + }); + }); + }); } getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData { - return apiToFuture( - this.api.analytics.getEnrollmentsQuery({ - programId: RTSL_ZEBRA_PROGRAM_ID, - dimension: [...EVENT_TRACKER_717_IDS.map(({ id }) => id)], - startDate: DEFAULT_START_DATE, - endDate: DEFAULT_END_DATE, - }) - ).flatMap(response => { - const filteredRow = filterAnalyticsEnrollmentDataByDiseaseOutbreakEvent( - diseaseOutbreakEventId, - response.rows, - response.headers - ); - - if (!filteredRow) - return Future.error(new Error("No data found for event tracker 7-1-7 performance")); - - const mappedIndicatorsToRows: string[][] = EVENT_TRACKER_717_IDS.map(({ id }) => { - return [ - id, - filteredRow[response.headers.findIndex(header => header.name === id)] || "", - ]; - }); + return this.datastore + .getObject(PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY) + .flatMap(nullable717PerformanceProgramIndicators => { + return assertOrError( + nullable717PerformanceProgramIndicators, + PERFORMANCE_717_PROGRAM_INDICATORS_DATASTORE_KEY + ).flatMap(performance717ProgramIndicators => { + const eventTracker717PerformanceIndicator = + performance717ProgramIndicators.filter( + indicator => indicator.key === "event_tracker" + ); + return apiToFuture( + this.api.analytics.getEnrollmentsQuery({ + programId: RTSL_ZEBRA_PROGRAM_ID, + dimension: [...eventTracker717PerformanceIndicator.map(({ id }) => id)], + startDate: DEFAULT_START_DATE, + endDate: DEFAULT_END_DATE, + }) + ).flatMap(response => { + const filteredRow = filterAnalyticsEnrollmentDataByDiseaseOutbreakEvent( + diseaseOutbreakEventId, + response.rows, + response.headers + ); - return Future.success( - this.mapIndicatorsTo717PerformanceMetrics( - mappedIndicatorsToRows, - EVENT_TRACKER_717_IDS - ) - ); - }); + if (!filteredRow) + return Future.error( + new Error("No data found for event tracker 7-1-7 performance") + ); + + const mappedIndicatorsToRows: string[][] = + eventTracker717PerformanceIndicator.map(({ id }) => { + return [ + id, + filteredRow[ + response.headers.findIndex(header => header.name === id) + ] || "", + ]; + }); + + return Future.success( + this.mapIndicatorsTo717PerformanceMetrics( + mappedIndicatorsToRows, + eventTracker717PerformanceIndicator + ) + ); + }); + }); + }); } private mapRowToBaseIndicator( row: string[], headers: { name: string; column: string }[], - metaData: AnalyticsResponse["metaData"] + metaData: AnalyticsResponse["metaData"], + performanceOverviewDimensions: PerformanceOverviewDimensions ): Partial { return headers.reduce((acc, header, index) => { - const key = Object.keys(IndicatorsId).find( - key => IndicatorsId[key as keyof typeof IndicatorsId] === header.name - ) as Maybe; + const key = Object.keys(performanceOverviewDimensions).find( + key => + performanceOverviewDimensions[key as keyof PerformanceOverviewDimensions] === + header.name + ) as Maybe; if (!key) return acc; - if (key === "suspectedDisease") { - acc[key] = - (( - Object.values(metaData.items).find( - item => (item as any).code === row[index] - ) as any - )?.name as DiseaseNames) || ""; - } else if (key === "hazardType") { - acc[key] = - (( - Object.values(metaData.items).find( - item => (item as any).code === row[index] - ) as any - )?.name as HazardNames) || ""; - } else if (key === "nationalIncidentStatus") { - acc[key] = row[index] as NationalIncidentStatus; - } else { - acc[key] = row[index] as (HazardNames & OrgUnit[]) | undefined; + switch (key) { + case "suspectedDisease": + acc.suspectedDisease = + (( + Object.values(metaData.items).find( + item => (item as any).code === row[index] + ) as any + )?.name as DiseaseNames) || ""; + break; + + case "hazardType": + acc.hazardType = + (( + Object.values(metaData.items).find( + item => (item as any).code === row[index] + ) as any + )?.name as HazardNames) || ""; + break; + + case "nationalIncidentStatus": + acc.nationalIncidentStatus = row[index] as NationalIncidentStatus; + break; + + case "teiId": + acc.id = row[index]; + break; + + case "era1ProgramIndicator": + acc.era1 = row[index]; + break; + + case "era2ProgramIndicator": + acc.era2 = row[index]; + break; + + case "era3ProgramIndicator": + acc.era3 = row[index]; + break; + + case "era4ProgramIndicator": + acc.era4 = row[index]; + break; + + case "era5ProgramIndicator": + acc.era5 = row[index]; + break; + + case "era6ProgramIndicator": + acc.era6 = row[index]; + break; + + case "era7ProgramIndicator": + acc.era7 = row[index]; + break; + + case "detect7dProgramIndicator": + acc.detect7d = row[index]; + break; + + case "notify1dProgramIndicator": + acc.notify1d = row[index]; + break; + + case "respond7dProgramIndicator": + acc.respond7d = row[index]; + break; + + default: + acc[key] = row[index]; + break; } return acc; diff --git a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts index d6a6b7e1..83b81a39 100644 --- a/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts +++ b/src/data/repositories/common/getProgramIndicatorsFromDatastore.ts @@ -4,7 +4,8 @@ import { DataStoreClient } from "../../DataStoreClient"; export enum ProgramIndicatorsDatastoreKey { ActiveVerifiedAlerts = "active-verified-alerts-program-indicators", - CasesAlerts = "cases-alerts-program-indicators", + SuspectedCasesAlertsProgram = "suspected-cases-alerts-program-indicators", + SuspectedCasesCasesProgram = "suspected-cases-cases-program-indicators", } export type ProgramIndicatorsDatastore = { @@ -20,11 +21,9 @@ export function getProgramIndicatorsFromDatastore( programIndicatorsDatastoreKey: ProgramIndicatorsDatastoreKey ): FutureData> { switch (programIndicatorsDatastoreKey) { + case ProgramIndicatorsDatastoreKey.SuspectedCasesAlertsProgram: case ProgramIndicatorsDatastoreKey.ActiveVerifiedAlerts: - return dataStoreClient.getObject( - programIndicatorsDatastoreKey - ); - case ProgramIndicatorsDatastoreKey.CasesAlerts: + case ProgramIndicatorsDatastoreKey.SuspectedCasesCasesProgram: return dataStoreClient.getObject( programIndicatorsDatastoreKey ); diff --git a/src/data/repositories/consts/PerformanceOverviewConstants.ts b/src/data/repositories/consts/PerformanceOverviewConstants.ts index 9dc91473..ae9946e0 100644 --- a/src/data/repositories/consts/PerformanceOverviewConstants.ts +++ b/src/data/repositories/consts/PerformanceOverviewConstants.ts @@ -2,28 +2,27 @@ import { DiseaseNames, HazardNames, IncidentStatus, - PerformanceMetrics717, } from "../../../domain/entities/disease-outbreak-event/PerformanceOverviewMetrics"; import { Id } from "../../../domain/entities/Ref"; -export enum IndicatorsId { - suspectedDisease = "jLvbkuvPdZ6", - hazardType = "Dzrw3Tf0ukB", - event = "fyrLOW9Iwwv", - era1 = "Ylmo2fEijff", - era2 = "w4FOvRAyjEE", - era3 = "RdLmpMM7lM5", - era4 = "xT4TgUZhMkk", - era5 = "UwEdN0kWFqv", - era6 = "xtetmvZ9WoV", - era7 = "GgUJMCklxFu", - detect7d = "cGFwM7qiPzl", - notify1d = "HDa3nE7Elxj", - respond7d = "yxVOW4lj4xP", - province = "ouname", - id = "tei", - nationalIncidentStatus = "incidentStatus", -} +export type PerformanceOverviewDimensions = { + teiId: "tei"; + event: Id; + province: "ouname"; + era1ProgramIndicator: Id; + era2ProgramIndicator: Id; + era3ProgramIndicator: Id; + era4ProgramIndicator: Id; + era5ProgramIndicator: Id; + era6ProgramIndicator: Id; + era7ProgramIndicator: Id; + detect7dProgramIndicator: Id; + notify1dProgramIndicator: Id; + respond7dProgramIndicator: Id; + suspectedDisease: Id; + hazardType: Id; + nationalIncidentStatus: "incidentStatus"; +}; type EventTrackerCountIndicatorBase = { id: Id; @@ -46,23 +45,3 @@ export type EventTrackerCountHazardIndicator = EventTrackerCountIndicatorBase & export type EventTrackerCountIndicator = | EventTrackerCountDiseaseIndicator | EventTrackerCountHazardIndicator; - -export const PERFORMANCE_METRICS_717_IDS: PerformanceMetrics717[] = [ - { id: "MFk8jiMSlfC", name: "detection", type: "primary" }, // % of number of alerts that were detected within 7 days of date of emergence - { id: "jD8CfKvvdXt", name: "detection", type: "secondary" }, // Number of alerts notified to public health authorities within 1 day of detection - - { id: "Y6OkqfhGhZb", name: "notification", type: "primary" }, // - { id: "fKvY7kMydl1", name: "notification", type: "secondary" }, // # events response action started 1 day - - { id: "gEVnF77Uz2u", name: "response", type: "primary" }, // % num of alerts responded d within 7d date not - { id: "ZX0uPp3ik81", name: "response", type: "secondary" }, // # events response action started 1 day - - { id: "bs4E7tV8QRN", name: "allTargets", type: "primary" }, // % num of alerts detected within 7d date emergence - { id: "NHP4GvI0O3J", name: "allTargets", type: "secondary" }, -]; - -export const EVENT_TRACKER_717_IDS: PerformanceMetrics717[] = [ - { id: "JuPtc83RFcy", name: "Days to detection", type: "primary" }, - { id: "fNnWRK0SBhD", name: "Days to notification", type: "primary" }, - { id: "dByeVE0Oqtu", name: "Days to early response", type: "primary" }, -]; diff --git a/src/data/repositories/test/ChartConfigTestRepository.ts b/src/data/repositories/test/ChartConfigTestRepository.ts index 8942a9bd..af0eb0db 100644 --- a/src/data/repositories/test/ChartConfigTestRepository.ts +++ b/src/data/repositories/test/ChartConfigTestRepository.ts @@ -1,3 +1,4 @@ +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../domain/entities/generic/Future"; import { ChartConfigRepository } from "../../../domain/repositories/ChartConfigRepository"; import { FutureData } from "../../api-futures"; @@ -6,10 +7,10 @@ export class ChartConfigTestRepository implements ChartConfigRepository { getRiskAssessmentHistory(_chartKey: string): FutureData { return Future.success("1"); } - getCases(_chartkey: string): FutureData { + getCases(_chartkey: string, _casesDataSource: CasesDataSource): FutureData { return Future.success("1"); } - getDeaths(_chartKey: string): FutureData { + getDeaths(_chartKey: string, _casesDataSource: CasesDataSource): FutureData { return Future.success("1"); } } diff --git a/src/data/repositories/test/MapConfigTestRepository.ts b/src/data/repositories/test/MapConfigTestRepository.ts index 4874e8dd..89ee4a95 100644 --- a/src/data/repositories/test/MapConfigTestRepository.ts +++ b/src/data/repositories/test/MapConfigTestRepository.ts @@ -1,10 +1,11 @@ +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { Future } from "../../../domain/entities/generic/Future"; import { MapConfig, MapKey } from "../../../domain/entities/MapConfig"; import { MapConfigRepository } from "../../../domain/repositories/MapConfigRepository"; import { FutureData } from "../../api-futures"; export class MapConfigTestRepository implements MapConfigRepository { - public get(_mapKey: MapKey): FutureData { + public get(_mapKey: MapKey, _casesDataSource?: CasesDataSource): FutureData { return Future.success({ currentApp: "ZEBRA", currentPage: "DASHBOARD", diff --git a/src/data/repositories/utils/DiseaseOutbreakMapper.ts b/src/data/repositories/utils/DiseaseOutbreakMapper.ts index 2cb3f179..36b23e0c 100644 --- a/src/data/repositories/utils/DiseaseOutbreakMapper.ts +++ b/src/data/repositories/utils/DiseaseOutbreakMapper.ts @@ -43,7 +43,9 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( const dataSource = dataSourceMap[fromMap("dataSource")]; const incidentStatus = incidentStatusMap[fromMap("incidentStatus")]; - const casesDataSource = casesDataSourceMap[fromMap("casesDataSource")]; + const casesDataSource = + casesDataSourceMap[fromMap("casesDataSource")] ?? + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR; if (!dataSource || !incidentStatus) throw new Error("Data source or incident status not valid"); @@ -100,7 +102,7 @@ export function mapTrackedEntityAttributesToDiseaseOutbreak( responseNarrative: fromMap("responseNarrative"), }, notes: fromMap("notes"), - casesDataSource: casesDataSource ?? CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_eIDSR, // To-do: check if this is correct + casesDataSource: casesDataSource, }; return diseaseOutbreak; diff --git a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts index f3c0bb22..9bc0727c 100644 --- a/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts +++ b/src/domain/entities/disease-outbreak-event/DiseaseOutbreakEvent.ts @@ -86,7 +86,7 @@ export type DiseaseOutbreakEventBaseAttrs = NamedRef & { earlyResponseActions: EarlyResponseActions; incidentManagerName: string; notes: Maybe; - casesDataSource: Code; + casesDataSource: CasesDataSource; }; export type DiseaseOutbreakEventAttrs = DiseaseOutbreakEventBaseAttrs & { diff --git a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts index 9a1c3634..9db7de02 100644 --- a/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts +++ b/src/domain/entities/disease-outbreak-event/PerformanceOverviewMetrics.ts @@ -79,4 +79,5 @@ export type PerformanceMetrics717 = { name: string; type: "primary" | "secondary"; value?: number | "Inc"; + key: "dashboard" | "event_tracker"; }; diff --git a/src/domain/repositories/ChartConfigRepository.ts b/src/domain/repositories/ChartConfigRepository.ts index 5f61084a..d9261004 100644 --- a/src/domain/repositories/ChartConfigRepository.ts +++ b/src/domain/repositories/ChartConfigRepository.ts @@ -1,7 +1,8 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; export interface ChartConfigRepository { - getCases(chartkey: string): FutureData; - getDeaths(chartKey: string): FutureData; + getCases(chartkey: string, casesDataSource: CasesDataSource): FutureData; + getDeaths(chartKey: string, casesDataSource: CasesDataSource): FutureData; getRiskAssessmentHistory(chartKey: string): FutureData; } diff --git a/src/domain/repositories/MapConfigRepository.ts b/src/domain/repositories/MapConfigRepository.ts index 85a4f76f..422f3deb 100644 --- a/src/domain/repositories/MapConfigRepository.ts +++ b/src/domain/repositories/MapConfigRepository.ts @@ -1,6 +1,7 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { MapConfig, MapKey } from "../entities/MapConfig"; export interface MapConfigRepository { - get(mapKey: MapKey): FutureData; + get(mapKey: MapKey, casesDataSource?: CasesDataSource): FutureData; } diff --git a/src/domain/repositories/PerformanceOverviewRepository.ts b/src/domain/repositories/PerformanceOverviewRepository.ts index 9fba312a..6704844a 100644 --- a/src/domain/repositories/PerformanceOverviewRepository.ts +++ b/src/domain/repositories/PerformanceOverviewRepository.ts @@ -1,5 +1,8 @@ import { FutureData } from "../../data/api-futures"; -import { DiseaseOutbreakEventBaseAttrs } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { + CasesDataSource, + DiseaseOutbreakEventBaseAttrs, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { TotalCardCounts, PerformanceOverviewMetrics, @@ -20,5 +23,8 @@ export interface PerformanceOverviewRepository { ): FutureData; getDashboard717Performance(): FutureData; getEventTracker717Performance(diseaseOutbreakEventId: Id): FutureData; - getEventTrackerOverviewMetrics(type: string): FutureData; + getEventTrackerOverviewMetrics( + type: string, + casesDataSource: CasesDataSource + ): FutureData; } diff --git a/src/domain/usecases/GetChartConfigByTypeUseCase.ts b/src/domain/usecases/GetChartConfigByTypeUseCase.ts index 0527ccbd..eeb100a0 100644 --- a/src/domain/usecases/GetChartConfigByTypeUseCase.ts +++ b/src/domain/usecases/GetChartConfigByTypeUseCase.ts @@ -1,19 +1,27 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../entities/generic/Future"; import { ChartConfigRepository } from "../repositories/ChartConfigRepository"; export type ChartType = "deaths" | "cases" | "risk-assessment-history"; export class GetChartConfigByTypeUseCase { constructor(private chartConfigRepository: ChartConfigRepository) {} - public execute(chartType: ChartType, chartKey: string): FutureData { - if (chartType === "deaths") { - return this.chartConfigRepository.getDeaths(chartKey); - } else if (chartType === "cases") { - return this.chartConfigRepository.getCases(chartKey); + public execute( + chartType: ChartType, + chartKey: string, + casesDataSource?: CasesDataSource + ): FutureData { + if (chartType === "deaths" && casesDataSource) { + return this.chartConfigRepository.getDeaths(chartKey, casesDataSource); + } else if (chartType === "cases" && casesDataSource) { + return this.chartConfigRepository.getCases(chartKey, casesDataSource); } else if (chartType === "risk-assessment-history") { return this.chartConfigRepository.getRiskAssessmentHistory(chartKey); } else { - throw new Error(`Invalid chart type: ${chartType}`); + return Future.error( + new Error(`Invalid chart type: ${chartType} or cases data source is missing`) + ); } } } diff --git a/src/domain/usecases/GetMapConfigUseCase.ts b/src/domain/usecases/GetMapConfigUseCase.ts index 4665e5a8..b5983eda 100644 --- a/src/domain/usecases/GetMapConfigUseCase.ts +++ b/src/domain/usecases/GetMapConfigUseCase.ts @@ -1,11 +1,12 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { MapConfig, MapKey } from "../entities/MapConfig"; import { MapConfigRepository } from "../repositories/MapConfigRepository"; export class GetMapConfigUseCase { constructor(private mapConfigRepository: MapConfigRepository) {} - public execute(mapKey: MapKey): FutureData { - return this.mapConfigRepository.get(mapKey); + public execute(mapKey: MapKey, casesDataSource?: CasesDataSource): FutureData { + return this.mapConfigRepository.get(mapKey, casesDataSource); } } diff --git a/src/domain/usecases/GetOverviewCardsUseCase.ts b/src/domain/usecases/GetOverviewCardsUseCase.ts index 2fdfa6ce..f93e5767 100644 --- a/src/domain/usecases/GetOverviewCardsUseCase.ts +++ b/src/domain/usecases/GetOverviewCardsUseCase.ts @@ -1,11 +1,15 @@ import { FutureData } from "../../data/api-futures"; +import { CasesDataSource } from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { OverviewCard } from "../entities/PerformanceOverview"; import { PerformanceOverviewRepository } from "../repositories/PerformanceOverviewRepository"; export class GetOverviewCardsUseCase { constructor(private performanceOverviewRepository: PerformanceOverviewRepository) {} - public execute(type: string): FutureData { - return this.performanceOverviewRepository.getEventTrackerOverviewMetrics(type); + public execute(type: string, casesDataSource: CasesDataSource): FutureData { + return this.performanceOverviewRepository.getEventTrackerOverviewMetrics( + type, + casesDataSource + ); } } diff --git a/src/webapp/components/chart/Chart.tsx b/src/webapp/components/chart/Chart.tsx index eb7b41bb..a084220e 100644 --- a/src/webapp/components/chart/Chart.tsx +++ b/src/webapp/components/chart/Chart.tsx @@ -6,19 +6,21 @@ import { useChart } from "./useChart"; import { Maybe } from "../../../utils/ts-utils"; import LoaderContainer from "../loader/LoaderContainer"; import { ChartType } from "../../../domain/usecases/GetChartConfigByTypeUseCase"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type ChartProps = { title: string; chartType: ChartType; chartKey: Maybe; + casesDataSource?: CasesDataSource; hasSeparator?: boolean; lastUpdated?: string; }; export const Chart: React.FC = React.memo(props => { const { api } = useAppContext(); - const { title, hasSeparator, lastUpdated, chartType, chartKey } = props; + const { title, hasSeparator, lastUpdated, chartType, chartKey, casesDataSource } = props; - const { id } = useChart(chartType, chartKey); + const { id } = useChart(chartType, chartKey, casesDataSource); const chartUrl = `${api.baseUrl}/dhis-web-data-visualizer/#/${id}`; diff --git a/src/webapp/components/chart/useChart.ts b/src/webapp/components/chart/useChart.ts index f803749d..fb520f55 100644 --- a/src/webapp/components/chart/useChart.ts +++ b/src/webapp/components/chart/useChart.ts @@ -2,8 +2,13 @@ import { useEffect, useState } from "react"; import { useAppContext } from "../../contexts/app-context"; import { Maybe } from "../../../utils/ts-utils"; import { ChartType } from "../../../domain/usecases/GetChartConfigByTypeUseCase"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; -export function useChart(chartType: ChartType, chartKey: Maybe) { +export function useChart( + chartType: ChartType, + chartKey: Maybe, + casesDataSource?: CasesDataSource +) { const { compositionRoot } = useAppContext(); const [id, setId] = useState(); @@ -11,7 +16,7 @@ export function useChart(chartType: ChartType, chartKey: Maybe) { if (!chartKey) { return; } - compositionRoot.charts.getCases.execute(chartType, chartKey).run( + compositionRoot.charts.getCases.execute(chartType, chartKey, casesDataSource).run( chartId => { setId(chartId); }, @@ -19,7 +24,7 @@ export function useChart(chartType: ChartType, chartKey: Maybe) { console.error(error); } ); - }, [chartKey, chartType, compositionRoot.charts.getCases]); + }, [casesDataSource, chartKey, chartType, compositionRoot.charts.getCases]); return { id }; } diff --git a/src/webapp/components/map/MapSection.tsx b/src/webapp/components/map/MapSection.tsx index f5e9c3d1..9fb5cdc6 100644 --- a/src/webapp/components/map/MapSection.tsx +++ b/src/webapp/components/map/MapSection.tsx @@ -7,6 +7,7 @@ import { useMap } from "./useMap"; import { MapKey } from "../../../domain/entities/MapConfig"; import LoaderContainer from "../loader/LoaderContainer"; import { useAppContext } from "../../contexts/app-context"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type MapSectionProps = { mapKey: MapKey; @@ -15,6 +16,7 @@ type MapSectionProps = { dateRangeFilter?: string[]; eventDiseaseCode?: string; eventHazardCode?: string; + casesDataSource?: CasesDataSource; }; export const MapSection: React.FC = React.memo(props => { @@ -26,6 +28,7 @@ export const MapSection: React.FC = React.memo(props => { dateRangeFilter, eventDiseaseCode, eventHazardCode, + casesDataSource, } = props; const { configurations } = useAppContext(); const snackbar = useSnackbar(); @@ -46,6 +49,7 @@ export const MapSection: React.FC = React.memo(props => { dateRangeFilter: dateRangeFilter, eventDiseaseCode: eventDiseaseCode, eventHazardCode: eventHazardCode, + casesDataSource: casesDataSource, }); const baseUrl = `${api.baseUrl}/api/apps/zebra-custom-maps-app/index.html`; diff --git a/src/webapp/components/map/useMap.ts b/src/webapp/components/map/useMap.ts index 952506ae..453eed89 100644 --- a/src/webapp/components/map/useMap.ts +++ b/src/webapp/components/map/useMap.ts @@ -7,6 +7,7 @@ import { } from "../../../domain/entities/MapConfig"; import i18n from "../../../utils/i18n"; import { Maybe } from "../../../utils/ts-utils"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; type LoadingState = { kind: "loading"; @@ -49,6 +50,7 @@ export function useMap(params: { allOrgUnitsIds: string[]; eventDiseaseCode?: string; eventHazardCode?: string; + casesDataSource?: CasesDataSource; dateRangeFilter?: string[]; singleSelectFilters?: Record; multiSelectFilters?: Record; @@ -58,6 +60,7 @@ export function useMap(params: { allOrgUnitsIds, eventDiseaseCode, eventHazardCode, + casesDataSource, dateRangeFilter, singleSelectFilters, multiSelectFilters, @@ -181,11 +184,16 @@ export function useMap(params: { ]); useEffect(() => { - if (mapKey === "event_tracker" && !eventDiseaseCode && !eventHazardCode) { + if ( + mapKey === "event_tracker" && + !eventDiseaseCode && + !eventHazardCode && + !casesDataSource + ) { return; } - compositionRoot.maps.getConfig.execute(mapKey).run( + compositionRoot.maps.getConfig.execute(mapKey, casesDataSource).run( config => { setMapProgramIndicators(config.programIndicators); setDefaultStartDate(config.startDate); @@ -233,7 +241,14 @@ export function useMap(params: { }); } ); - }, [compositionRoot.maps.getConfig, mapKey, allOrgUnitsIds, eventDiseaseCode, eventHazardCode]); + }, [ + compositionRoot.maps.getConfig, + mapKey, + allOrgUnitsIds, + eventDiseaseCode, + eventHazardCode, + casesDataSource, + ]); return { mapConfigState, diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 42e15157..dcd96f82 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -108,6 +108,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { eventDiseaseCode={currentEventTracker?.suspectedDiseaseCode} eventHazardCode={currentEventTracker?.hazardType} dateRangeFilter={dateRangeFilter.value || []} + casesDataSource={currentEventTracker?.casesDataSource} /> @@ -185,6 +186,7 @@ export const EventTrackerPage: React.FC = React.memo(() => { currentEventTracker?.suspectedDisease?.name || currentEventTracker?.hazardType } + casesDataSource={currentEventTracker?.casesDataSource} /> { currentEventTracker?.suspectedDisease?.name || currentEventTracker?.hazardType } + casesDataSource={currentEventTracker?.casesDataSource} />
{ const type = currentEventTracker?.suspectedDiseaseCode || currentEventTracker?.hazardType; - if (type) { + const casesDataSource = currentEventTracker?.casesDataSource; + + if (type && casesDataSource) { setIsLoading(true); - compositionRoot.performanceOverview.getOverviewCards.execute(type).run( + compositionRoot.performanceOverview.getOverviewCards.execute(type, casesDataSource).run( overviewCards => { setIsLoading(false); setOverviewCards(overviewCards); From 529cf54a0efd07bf92a7436a1b157a71393c07aa Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Fri, 13 Dec 2024 14:12:23 +0100 Subject: [PATCH 06/21] Add map based in uploaded case data --- src/data/repositories/MapConfigD2Repository.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/data/repositories/MapConfigD2Repository.ts b/src/data/repositories/MapConfigD2Repository.ts index bb96f716..79479363 100644 --- a/src/data/repositories/MapConfigD2Repository.ts +++ b/src/data/repositories/MapConfigD2Repository.ts @@ -46,7 +46,10 @@ export class MapConfigD2Repository implements MapConfigRepository { const mapConfigDataStore = mapKey === "dashboard" ? mapsConfigDatastore?.dashboard - : mapsConfigDatastore?.event_tracker; + : casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ? mapsConfigDatastore?.event_tracker_cases + : mapsConfigDatastore?.event_tracker_alerts; + return getProgramIndicatorsFromDatastore( this.dataStoreClient, programIndicatorsDatastoreKey @@ -86,7 +89,8 @@ export class MapConfigD2Repository implements MapConfigRepository { type MapsConfigDatastore = { dashboard: MapConfigDatastore; - event_tracker: MapConfigDatastore; + event_tracker_alerts: MapConfigDatastore; + event_tracker_cases: MapConfigDatastore; }; type MapConfigDatastore = { From f0efebb1ff2900fc81d68412dbf88b6770fd746a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Mon, 16 Dec 2024 16:41:53 +0100 Subject: [PATCH 07/21] Get data for performance table in dashboard depending on case data source --- src/data/repositories/PerformanceOverviewD2Repository.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/data/repositories/PerformanceOverviewD2Repository.ts b/src/data/repositories/PerformanceOverviewD2Repository.ts index b212c2c8..86f9cbb9 100644 --- a/src/data/repositories/PerformanceOverviewD2Repository.ts +++ b/src/data/repositories/PerformanceOverviewD2Repository.ts @@ -511,7 +511,10 @@ export class PerformanceOverviewD2Repository implements PerformanceOverviewRepos ); const currentEventTrackerOverview = eventTrackerOverviewsForKeys.find( - overview => overview.key === key + overview => + overview.key === key && + overview.casesDataSource === + event.casesDataSource ); const currentCases = allCases.find( From 0630e7bfac040ed826cdabcab4eb2d8bf85d342a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Tue, 17 Dec 2024 09:46:16 +0100 Subject: [PATCH 08/21] When a national event is completed then mark case events as completed and remove info from datastore --- .../AlertSyncDataStoreRepository.ts | 2 + .../repositories/CasesFileD2Repository.ts | 5 +- .../DiseaseOutbreakEventD2Repository.ts | 36 +++++++++++- .../entities/AlertsAndCaseForCasesData.ts | 1 + .../repositories/CasesFileRepository.ts | 4 +- .../usecases/CompleteEventTrackerUseCase.ts | 39 ++++++++++++- .../event-tracker/useDiseaseOutbreakEvent.ts | 55 ++++++++++--------- 7 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/data/repositories/AlertSyncDataStoreRepository.ts b/src/data/repositories/AlertSyncDataStoreRepository.ts index 72566348..082e2b7d 100644 --- a/src/data/repositories/AlertSyncDataStoreRepository.ts +++ b/src/data/repositories/AlertSyncDataStoreRepository.ts @@ -63,6 +63,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ? synchronizationData : { ...outbreakData, + lastSyncTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), alerts: [ ...(outbreakData.alerts || []), @@ -133,6 +134,7 @@ export class AlertSyncDataStoreRepository implements AlertSyncRepository { ) ?? []; return { + lastSyncTime: new Date().toISOString(), lastUpdated: new Date().toISOString(), nationalDiseaseOutbreakEventId: nationalDiseaseOutbreakEventId, [outbreakType]: outbreakKey, diff --git a/src/data/repositories/CasesFileD2Repository.ts b/src/data/repositories/CasesFileD2Repository.ts index ea08ddbe..4a0fa353 100644 --- a/src/data/repositories/CasesFileD2Repository.ts +++ b/src/data/repositories/CasesFileD2Repository.ts @@ -12,7 +12,7 @@ import { AppDatastoreConfig } from "../../domain/entities/AppDatastoreConfig"; export class CasesFileD2Repository implements CasesFileRepository { constructor(private api: D2Api, private dataStoreClient: DataStoreClient) {} - get(outbreakKey: Id): FutureData { + get(outbreakKey: string): FutureData { return this.getAlertsAndCaseForCasesDataObject(outbreakKey).flatMap( alertsAndCaseForCasesData => { if ( @@ -57,6 +57,7 @@ export class CasesFileD2Repository implements CasesFileRepository { }).flatMap(({ fileId, alertsAndCaseForCasesData }) => { const newAlertsAndCaseForCasesData: AlertsAndCaseForCasesData = { ...(alertsAndCaseForCasesData || {}), + lastSyncTime: alertsAndCaseForCasesData?.lastSyncTime || "", lastUpdated: new Date().toISOString(), nationalDiseaseOutbreakEventId: diseaseOutbreakEventId, case: { @@ -72,7 +73,7 @@ export class CasesFileD2Repository implements CasesFileRepository { }); } - delete(outbreakKey: Id): FutureData { + delete(outbreakKey: string): FutureData { return this.getAlertsAndCaseForCasesDataObject(outbreakKey).flatMap( alertsAndCaseForCasesData => { if (!alertsAndCaseForCasesData?.case?.fileId) diff --git a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts index ff17078e..3ff2b652 100644 --- a/src/data/repositories/DiseaseOutbreakEventD2Repository.ts +++ b/src/data/repositories/DiseaseOutbreakEventD2Repository.ts @@ -159,12 +159,44 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep return Future.error( new Error(`Error completing disease outbreak event : ${response.message}`) ); + } else { + return this.markCasesDataAsCompleted(id).flatMap(() => + Future.success(undefined) + ); + } + }); + }); + } + + private markCasesDataAsCompleted(diseaseOutbreakId: Id): FutureData { + return this.getd2EventCasesDataByDiseaseOutbreakId(diseaseOutbreakId).flatMap(d2Events => { + if (!d2Events.length) { + return Future.success(undefined); + } + + const d2CompletedEvents = d2Events.map( + (d2Event: D2TrackerEvent): D2TrackerEvent => ({ + ...d2Event, + status: "COMPLETED", + }) + ); + return apiToFuture( + this.api.tracker.post({ importStrategy: "UPDATE" }, { events: d2CompletedEvents }) + ).flatMap(response => { + if (response.status !== "OK") { + return Future.error( + new Error( + `Error while marking the cases data as completed: ${response.message}` + ) + ); } else return Future.success(undefined); }); }); } - private getCasesDataByDiseaseOutbreakId(diseaseOutbreakId: Id): FutureData { + private getd2EventCasesDataByDiseaseOutbreakId( + diseaseOutbreakId: Id + ): FutureData { return apiToFuture( this.api.tracker.events.get({ program: RTSL_ZEBRA_CASE_PROGRAM_ID, @@ -231,7 +263,7 @@ export class DiseaseOutbreakEventD2Repository implements DiseaseOutbreakEventRep } private deleteCasesData(diseaseOutbreak: DiseaseOutbreakEvent): FutureData { - return this.getCasesDataByDiseaseOutbreakId(diseaseOutbreak.id).flatMap(d2Events => { + return this.getd2EventCasesDataByDiseaseOutbreakId(diseaseOutbreak.id).flatMap(d2Events => { return apiToFuture( this.api.tracker.post({ importStrategy: "DELETE" }, { events: d2Events }) ).flatMap(response => { diff --git a/src/domain/entities/AlertsAndCaseForCasesData.ts b/src/domain/entities/AlertsAndCaseForCasesData.ts index 836b311d..42cbb299 100644 --- a/src/domain/entities/AlertsAndCaseForCasesData.ts +++ b/src/domain/entities/AlertsAndCaseForCasesData.ts @@ -3,6 +3,7 @@ import { DataSource } from "./disease-outbreak-event/DiseaseOutbreakEvent"; import { Id, Option } from "./Ref"; export type AlertsAndCaseForCasesData = { + lastSyncTime: string; lastUpdated: string; nationalDiseaseOutbreakEventId: Id; alerts?: { diff --git a/src/domain/repositories/CasesFileRepository.ts b/src/domain/repositories/CasesFileRepository.ts index 803c0a59..a31d3e5c 100644 --- a/src/domain/repositories/CasesFileRepository.ts +++ b/src/domain/repositories/CasesFileRepository.ts @@ -3,8 +3,8 @@ import { CaseFile } from "../entities/CasesFile"; import { Id } from "../entities/Ref"; export interface CasesFileRepository { - get(outbreakKey: Id): FutureData; + get(outbreakKey: string): FutureData; getTemplate(): FutureData; save(diseaseOutbreakEventId: Id, outbreakKey: string, file: CaseFile): FutureData; - delete(outbreakKey: Id): FutureData; + delete(outbreakKey: string): FutureData; } diff --git a/src/domain/usecases/CompleteEventTrackerUseCase.ts b/src/domain/usecases/CompleteEventTrackerUseCase.ts index ec08661b..2f9c82c7 100644 --- a/src/domain/usecases/CompleteEventTrackerUseCase.ts +++ b/src/domain/usecases/CompleteEventTrackerUseCase.ts @@ -1,15 +1,48 @@ import { FutureData } from "../../data/api-futures"; -import { Id } from "../entities/Ref"; +import { getOutbreakKey } from "../entities/AlertsAndCaseForCasesData"; +import { Configurations } from "../entities/AppConfigurations"; +import { + CasesDataSource, + DiseaseOutbreakEvent, +} from "../entities/disease-outbreak-event/DiseaseOutbreakEvent"; +import { Future } from "../entities/generic/Future"; +import { CasesFileRepository } from "../repositories/CasesFileRepository"; import { DiseaseOutbreakEventRepository } from "../repositories/DiseaseOutbreakEventRepository"; export class CompleteEventTrackerUseCase { constructor( private options: { diseaseOutbreakEventRepository: DiseaseOutbreakEventRepository; + casesFileRepository: CasesFileRepository; } ) {} - public execute(id: Id): FutureData { - return this.options.diseaseOutbreakEventRepository.complete(id); + public execute( + diseaseOutbreakEvent: DiseaseOutbreakEvent, + configurations: Configurations + ): FutureData { + return this.options.diseaseOutbreakEventRepository + .complete(diseaseOutbreakEvent.id) + .flatMap(() => { + if ( + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF + ) { + const outbreakKey = getOutbreakKey({ + dataSource: diseaseOutbreakEvent.dataSource, + outbreakValue: + diseaseOutbreakEvent.suspectedDiseaseCode || + diseaseOutbreakEvent.hazardType, + hazardTypes: + configurations.selectableOptions.eventTrackerConfigurations.hazardTypes, + suspectedDiseases: + configurations.selectableOptions.eventTrackerConfigurations + .suspectedDiseases, + }); + return this.options.casesFileRepository.delete(outbreakKey); + } else { + return Future.success(undefined); + } + }); } } diff --git a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts index ba003c21..8d3720b0 100644 --- a/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts +++ b/src/webapp/pages/event-tracker/useDiseaseOutbreakEvent.ts @@ -180,37 +180,42 @@ export function useDiseaseOutbreakEvent(id: Id) { ); const onCompleteClick = useCallback(() => { - compositionRoot.diseaseOutbreakEvent.complete.execute(id).run( - () => { - const eventTrackerName = - eventTrackerDetails?.hazardType ?? eventTrackerDetails?.suspectedDisease?.name; + if (eventTrackerDetails) { + compositionRoot.diseaseOutbreakEvent.complete + .execute(eventTrackerDetails, configurations) + .run( + () => { + const eventTrackerName = + eventTrackerDetails?.hazardType ?? + eventTrackerDetails?.suspectedDisease?.name; - const updatedEventTrackerTypes = existingEventTrackerTypes.filter( - eventTrackerType => eventTrackerType !== eventTrackerName - ); + const updatedEventTrackerTypes = existingEventTrackerTypes.filter( + eventTrackerType => eventTrackerType !== eventTrackerName + ); - if (eventTrackerName) { - changeExistingEventTrackerTypes(updatedEventTrackerTypes); - } + if (eventTrackerName) { + changeExistingEventTrackerTypes(updatedEventTrackerTypes); + } - setGlobalMessage({ - type: "success", - text: `Event tracker with id: ${id} has been completed`, - }); - }, - err => { - console.error(err); - setGlobalMessage({ - type: "error", - text: `Failed to complete event: : ${err.message}`, - }); - } - ); + setGlobalMessage({ + type: "success", + text: `Event tracker with id: ${id} has been completed`, + }); + }, + err => { + console.error(err); + setGlobalMessage({ + type: "error", + text: `Failed to complete event: : ${err.message}`, + }); + } + ); + } }, [ changeExistingEventTrackerTypes, compositionRoot.diseaseOutbreakEvent.complete, - eventTrackerDetails?.hazardType, - eventTrackerDetails?.suspectedDisease?.name, + configurations, + eventTrackerDetails, existingEventTrackerTypes, id, ]); From b4d676a461b62f2a0b2efcb5fb1acb70d7ff960d Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 18 Dec 2024 18:43:20 +0100 Subject: [PATCH 09/21] Modify template and adapt code --- src/webapp/components/form/FieldWidget.tsx | 5 +- src/webapp/components/form/FormFieldsState.ts | 9 +- .../components/import-file/ImportFile.tsx | 37 ++-- .../CaseDataFileFieldHelper.ts | 190 ++++++++++++++---- .../pages/form-page/utils/FileHelper.ts | 7 +- 5 files changed, 183 insertions(+), 65 deletions(-) diff --git a/src/webapp/components/form/FieldWidget.tsx b/src/webapp/components/form/FieldWidget.tsx index 636e63e5..ec823daa 100644 --- a/src/webapp/components/form/FieldWidget.tsx +++ b/src/webapp/components/form/FieldWidget.tsx @@ -22,8 +22,8 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E const { field, onChange, disabled = false, errorLabels } = props; const notifyChange = useCallback( - (newValue: FormFieldState["value"], sheetData?: SheetData) => { - onChange(updateFieldState(field, newValue, sheetData)); + (newValue: FormFieldState["value"], sheetsData?: SheetData[]) => { + onChange(updateFieldState(field, newValue, sheetsData)); }, [field, onChange] ); @@ -104,6 +104,7 @@ export const FieldWidget: React.FC = React.memo((props): JSX.E file={field.value} onChange={notifyChange} fileTemplate={field.fileTemplate} + fileId={field.fileId} /> ); } diff --git a/src/webapp/components/form/FormFieldsState.ts b/src/webapp/components/form/FormFieldsState.ts index a3b6d9d2..d2fc59d0 100644 --- a/src/webapp/components/form/FormFieldsState.ts +++ b/src/webapp/components/form/FormFieldsState.ts @@ -68,12 +68,13 @@ export type FormAvatarFieldState = FormFieldStateBase> & { export type Row = Record; export type SheetData = { + name: string; headers: string[]; rows: Row[]; }; export type FormFileFieldState = FormFieldStateBase> & { type: "file"; - data: Maybe; + data: Maybe; fileId: Maybe; fileTemplate: Maybe; }; @@ -131,7 +132,7 @@ export function getFileFieldValue(id: string, allFields: FormFieldState[]): Mayb return getFieldValueById(id, allFields); } -export function getFieldFileDataById(id: string, allFields: FormFieldState[]): Maybe { +export function getFieldFileDataById(id: string, allFields: FormFieldState[]): Maybe { const field = allFields.find(field => field.id === id && field.type === "file"); return field?.type === "file" ? field.data : undefined; } @@ -214,13 +215,13 @@ export function updateFields( export function updateFieldState( fieldToUpdate: F, newValue: F["value"], - sheetData?: SheetData + sheetsData?: SheetData[] ): F { if (fieldToUpdate.type === "file") { return { ...fieldToUpdate, value: newValue, - data: sheetData, + data: sheetsData, fileId: undefined, // If a new file is uploaded, the fileId should be undefined }; } else { diff --git a/src/webapp/components/import-file/ImportFile.tsx b/src/webapp/components/import-file/ImportFile.tsx index 91ad025f..910aafea 100644 --- a/src/webapp/components/import-file/ImportFile.tsx +++ b/src/webapp/components/import-file/ImportFile.tsx @@ -14,12 +14,14 @@ import { IconButton } from "../icon-button/IconButton"; import { SimpleModal } from "../simple-modal/SimpleModal"; import { readFile } from "../../pages/form-page/utils/FileHelper"; import { SheetData } from "../form/FormFieldsState"; +import { Id } from "../../../domain/entities/Ref"; type ImportFileProps = { id: string; fileTemplate: Maybe; file: Maybe; - onChange: (file: File | undefined, sheetData: SheetData | undefined) => void; + fileId: Maybe; + onChange: (file: File | undefined, sheetsData: SheetData[] | undefined) => void; label?: string; placeholder?: string; disabled?: boolean; @@ -30,8 +32,19 @@ type ImportFileProps = { }; export const ImportFile: React.FC = React.memo(props => { - const { file, onChange, label, id, helperText, errorText, error, required, fileTemplate } = - props; + const { + file, + onChange, + label, + id, + helperText, + errorText, + error, + required, + fileTemplate, + fileId, + } = props; + const snackbar = useSnackbar(); const dropzoneRef = useRef(null); const [openDeleteModal, setOpenDeleteModal] = useState(false); @@ -49,13 +62,7 @@ export const ImportFile: React.FC = React.memo(props => { snackbar.error(i18n.t("Multiple uploads not allowed, please select one file")); } else { const spreadsheets = await readFile(uploadedFile); - const spreadsheet = spreadsheets[0]; // only one sheet - const headerRow = spreadsheet?.headers; - const rows = spreadsheet?.rows; - onChange(uploadedFile, { - headers: headerRow || [], - rows: rows || [], - }); + onChange(uploadedFile, spreadsheets); } }; @@ -125,7 +132,7 @@ export const ImportFile: React.FC = React.memo(props => { download={fileTemplate.name} underline="hover" > - {fileTemplate.name} + {i18n.t("Download empty template")} )} @@ -137,8 +144,12 @@ export const ImportFile: React.FC = React.memo(props => { ariaLabel="Delete current uploaded file" onClick={onOpenConfirmationModalRemoveFile} /> - - {file.name} + + {fileId ? i18n.t("Download historical data") : file.name} sheetData.name === SHEET_NAMES.dataEntry) + ) { return { property: diseaseOutbreakEventFieldIds.casesDataFile, value: updatedField.value, errors: ["file_missing"], }; + } + + const dataEntrySheetData = updatedField.data?.find( + sheetData => sheetData.name === SHEET_NAMES.dataEntry + ); + const metadataSheetData = updatedField.data?.find( + sheetData => sheetData.name === SHEET_NAMES.metadata + ); - if (updatedField.data.headers.length === 0 || updatedField.data.rows.length === 0) { + if ( + !dataEntrySheetData || + !metadataSheetData || + dataEntrySheetData.headers.length === 0 || + dataEntrySheetData.rows.length === 0 || + metadataSheetData.headers.length === 0 || + metadataSheetData.rows.length === 0 + ) { return { property: diseaseOutbreakEventFieldIds.casesDataFile, value: updatedField.value, @@ -42,32 +72,58 @@ export function validateCaseSheetData( }; } - const casesDataHeadersNotPresent = REQUIRED_COLUMN_HEADERS.filter( - header => !doesColumnExist(updatedField?.data?.headers || [], header) + const casesDataEntryHeadersNotPresent = Object.values( + REQUIRED_DATA_ENTRY_COLUMN_HEADERS + ).filter(header => !doesColumnExist(dataEntrySheetData.headers || [], header)); + + const metadataHeadersNotPresent = Object.values(REQUIRED_METADATA_COLUMN_HEADERS).filter( + header => !doesColumnExist(metadataSheetData.headers || [], header) ); - const allDatesInColumn = updatedField.data.rows.map(row => - row["DATE(YYYY-MM-DD)"] ? formatDateToDateString(row["DATE(YYYY-MM-DD)"]) : undefined + const casesDataEntryRows = mapDistrictNamesToDistrictIdsInDataEntryRows( + dataEntrySheetData.rows, + metadataSheetData.rows ); + const allDistrictsAndDatesCombinationsInColumn = casesDataEntryRows.map(row => { + const reportDate = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.reportDate]; + const districtId = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]; + if (reportDate) { + return reportDate ? `${districtId}-${formatDateToDateString(reportDate)}` : undefined; + } + }); + const allOrgUnitIds = orgUnits.map(orgUnit => orgUnit.id); - const lineWithErrors = updatedField.data.rows.reduce( + const lineWithErrors = casesDataEntryRows.reduce( (errors, row, index): Partial> => { const orgUnitIncorrect = - !row["ORG UNIT"] || !allOrgUnitIds.includes(row["ORG UNIT"] || ""); + !row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district] || + !allOrgUnitIds.includes(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district] || ""); + + const reportDate = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.reportDate]; + const reportDateString = reportDate ? formatDateToDateString(reportDate) : undefined; + const dateNotPresent = !reportDateString; - const dateString = row["DATE(YYYY-MM-DD)"] - ? formatDateToDateString(row["DATE(YYYY-MM-DD)"]) - : undefined; + const districtAndDateCombination = `${ + row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district] + }-${reportDateString}`; - const dateNotPresent = !dateString; - const repeatedDate = dateString && allDatesInColumn.indexOf(dateString) !== index; + const repeatedDistrictDateCombination = + reportDateString && + allDistrictsAndDatesCombinationsInColumn.indexOf(districtAndDateCombination) !== + index; - const suspectedNotPresent = isNaN(Number(row["SUSPECTED"])); - const probableNotPresent = isNaN(Number(row["PROBABLE"])); - const confirmedNotPresent = isNaN(Number(row["CONFIRMED"])); - const deathsNotPresent = isNaN(Number(row["DEATHS"])); + const suspectedNotPresent = isNaN( + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.suspectedCases]) + ); + const probableNotPresent = isNaN( + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.probableCases]) + ); + const confirmedNotPresent = isNaN( + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.confirmedCases]) + ); + const deathsNotPresent = isNaN(Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.deaths])); return { [file_org_units_incorrect]: orgUnitIncorrect @@ -76,7 +132,7 @@ export function validateCaseSheetData( [file_dates_missing]: dateNotPresent ? [...(errors[file_dates_missing] || []), index + 2] : errors[file_dates_missing], - [file_dates_not_unique]: repeatedDate + [file_dates_not_unique]: repeatedDistrictDateCombination ? [...(errors[file_dates_not_unique] || []), index + 2] : errors[file_dates_not_unique], [file_data_not_number]: @@ -101,10 +157,19 @@ export function validateCaseSheetData( value: updatedField.value, errors: [], errorsInFile: { - [file_headers_missing]: casesDataHeadersNotPresent.length - ? `${casesDataHeadersNotPresent.join( + [file_headers_missing]: casesDataEntryHeadersNotPresent.length + ? `${casesDataEntryHeadersNotPresent.join( ", " - )} headers in file are missing. Correct headers are: DATE(YYYY-MM-DD), SUSPECTED, PROBABLE, CONFIRMED, DEATHS` + )} headers in Data entry sheet are missing. Correct headers are: ${Object.values( + REQUIRED_DATA_ENTRY_COLUMN_HEADERS + ).join(", ")}` + : undefined, + [file_headers_missing]: metadataHeadersNotPresent.length + ? `${metadataHeadersNotPresent.join( + ", " + )} headers in Metadata sheet are missing. Correct headers are: ${Object.values( + REQUIRED_METADATA_COLUMN_HEADERS + ).join(", ")}` : undefined, [file_org_units_incorrect]: lineWithErrors[file_org_units_incorrect]?.length ? `Org unit id is incorrect in row(s): ${lineWithErrors[ @@ -124,28 +189,67 @@ export function validateCaseSheetData( }; } +export function mapDistrictNamesToDistrictIdsInDataEntryRows( + dataEntryRows: Row[], + metadataRows: Row[] +): Row[] { + return dataEntryRows.map(row => { + const districtName = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]; + const districtId = metadataRows.find( + metadataRow => metadataRow[REQUIRED_METADATA_COLUMN_HEADERS.name] === districtName + )?.[REQUIRED_METADATA_COLUMN_HEADERS.identifier]; + + return { + ...row, + [REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]: districtId || "", + }; + }); +} + export function getCaseDataFromField( - uploadedCasesSheetData: SheetData, + uploadedCasesSheetData: SheetData[], currentUsername: Username ): CaseData[] { - if (!uploadedCasesSheetData?.headers || !uploadedCasesSheetData?.rows) { + const dataEntrySheetData = uploadedCasesSheetData.find( + sheetData => sheetData.name === SHEET_NAMES.dataEntry + ); + const metadataSheetData = uploadedCasesSheetData.find( + sheetData => sheetData.name === SHEET_NAMES.metadata + ); + + if ( + !dataEntrySheetData?.headers || + !dataEntrySheetData?.rows || + !metadataSheetData?.rows || + !metadataSheetData?.headers + ) { throw new Error("Case data file is missing"); } - const casesData: CaseData[] = uploadedCasesSheetData.rows + + const casesDataEntryRows = mapDistrictNamesToDistrictIdsInDataEntryRows( + dataEntrySheetData.rows, + metadataSheetData.rows + ); + + const casesData: CaseData[] = casesDataEntryRows .map(row => { - const dateString = row["DATE(YYYY-MM-DD)"] - ? formatDateToDateString(row["DATE(YYYY-MM-DD)"]) - : undefined; + const reportDate = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.reportDate]; + const reportDateString = reportDate ? formatDateToDateString(reportDate) : undefined; + + const districtId = row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.district]; - if (dateString && row["ORG UNIT"]) { + if (reportDateString && districtId) { return { updatedBy: currentUsername, - reportDate: dateString, - orgUnit: row["ORG UNIT"], - suspectedCases: Number(row["SUSPECTED"]), - probableCases: Number(row["PROBABLE"]), - confirmedCases: Number(row["CONFIRMED"]), - deaths: Number(row["DEATHS"]), + reportDate: reportDateString, + orgUnit: districtId, + suspectedCases: + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.suspectedCases]) || 0, + probableCases: + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.probableCases]) || 0, + confirmedCases: + Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.confirmedCases]) || 0, + deaths: Number(row[REQUIRED_DATA_ENTRY_COLUMN_HEADERS.deaths]) || 0, }; } }) diff --git a/src/webapp/pages/form-page/utils/FileHelper.ts b/src/webapp/pages/form-page/utils/FileHelper.ts index 89dd7390..33237785 100644 --- a/src/webapp/pages/form-page/utils/FileHelper.ts +++ b/src/webapp/pages/form-page/utils/FileHelper.ts @@ -5,7 +5,7 @@ import { Row, SheetData } from "../../../components/form/FormFieldsState"; export async function readFile(file: File): Promise { const workbook = XLSX.read(await file.arrayBuffer(), { cellDates: true }); - return Object.values(workbook.Sheets).map((worksheet): SheetData => { + return Object.entries(workbook.Sheets).map(([sheetName, worksheet]): SheetData => { const headers = XLSX.utils.sheet_to_json(worksheet, { header: 1, @@ -17,8 +17,9 @@ export async function readFile(file: File): Promise { }); return { - headers, - rows, + name: sheetName, + headers: headers, + rows: rows, }; }); } From 5c7cbbd8e3716b317024fafebf4000b0f324aa62 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Wed, 18 Dec 2024 18:47:51 +0100 Subject: [PATCH 10/21] Update translations --- i18n/en.pot | 10 ++++++++-- i18n/es.po | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index d28d72d2..08f217d5 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-11-27T21:40:09.970Z\n" -"PO-Revision-Date: 2024-11-27T21:40:09.970Z\n" +"POT-Creation-Date: 2024-12-18T17:43:51.092Z\n" +"PO-Revision-Date: 2024-12-18T17:43:51.092Z\n" msgid "Low" msgstr "" @@ -114,6 +114,12 @@ msgstr "" msgid "Close" msgstr "" +msgid "Download empty template" +msgstr "" + +msgid "Download historical data" +msgstr "" + msgid "Confirm remove the file" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 03c868c7..86c3980b 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2024-11-27T21:40:09.970Z\n" +"POT-Creation-Date: 2024-12-18T17:43:51.092Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -113,6 +113,12 @@ msgstr "" msgid "Close" msgstr "" +msgid "Download empty template" +msgstr "" + +msgid "Download historical data" +msgstr "" + msgid "Confirm remove the file" msgstr "" From f9cfc93e47f9eb2fd5409149e291fe72b0e4a47a Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Thu, 19 Dec 2024 17:36:37 +0100 Subject: [PATCH 11/21] Add warning message when changing cases data file --- src/webapp/components/form/Form.tsx | 47 ++- .../components/form/__tests__/Form.spec.tsx | 2 + src/webapp/pages/form-page/FormPage.tsx | 6 + src/webapp/pages/form-page/useForm.ts | 294 ++++++++++++------ 4 files changed, 246 insertions(+), 103 deletions(-) diff --git a/src/webapp/components/form/Form.tsx b/src/webapp/components/form/Form.tsx index 8493665f..3c63e385 100644 --- a/src/webapp/components/form/Form.tsx +++ b/src/webapp/components/form/Form.tsx @@ -1,4 +1,5 @@ import React from "react"; +import styled from "styled-components"; import { useLocalForm } from "./useLocalForm"; import { FormState } from "./FormState"; @@ -6,10 +7,23 @@ import { FormLayout } from "./FormLayout"; import { FormSection } from "./FormSection"; import { Layout } from "../layout/Layout"; import { FormFieldState } from "./FormFieldsState"; +import { SimpleModal } from "../simple-modal/SimpleModal"; +import { Button } from "../button/Button"; + +export type ModalData = { + title: string; + content: string; + cancelLabel: string; + confirmLabel: string; + onConfirm: () => void; +}; export type FormProps = { formState: FormState; errorLabels?: Record; + openModal: boolean; + modalData?: ModalData; + setOpenModal: (show: boolean) => void; onFormChange: (updatedField: FormFieldState) => void; onSave: () => void; onCancel?: () => void; @@ -18,8 +32,18 @@ export type FormProps = { }; export const Form: React.FC = React.memo(props => { - const { formState, onFormChange, onSave, onCancel, errorLabels, handleAddNew, handleRemove } = - props; + const { + formState, + onFormChange, + onSave, + onCancel, + errorLabels, + handleAddNew, + handleRemove, + openModal, + modalData, + setOpenModal, + } = props; const { formLocalState, handleUpdateFormField } = useLocalForm(formState, onFormChange); return ( @@ -57,6 +81,25 @@ export const Form: React.FC = React.memo(props => { ); })} + {modalData ? ( + setOpenModal(false)} + title={modalData.title} + closeLabel={modalData.cancelLabel} + footerButtons={ + + } + > + {openModal && {modalData.content}} + + ) : null} ); }); + +const Text = styled.div` + font-weight: 400; + font-size: 0.875rem; + color: ${props => props.theme.palette.common.grey900}; +`; diff --git a/src/webapp/components/form/__tests__/Form.spec.tsx b/src/webapp/components/form/__tests__/Form.spec.tsx index 9765ee1c..37255e20 100644 --- a/src/webapp/components/form/__tests__/Form.spec.tsx +++ b/src/webapp/components/form/__tests__/Form.spec.tsx @@ -490,5 +490,7 @@ function givenFormProps(): FormProps { onFormChange: (_updatedField: FormFieldState) => {}, onSave: () => {}, onCancel: () => {}, + openModal: false, + setOpenModal: () => {}, }; } diff --git a/src/webapp/pages/form-page/FormPage.tsx b/src/webapp/pages/form-page/FormPage.tsx index 5408a886..146f010e 100644 --- a/src/webapp/pages/form-page/FormPage.tsx +++ b/src/webapp/pages/form-page/FormPage.tsx @@ -29,6 +29,9 @@ export const FormPage: React.FC = React.memo(() => { globalMessage, formState, isLoading, + openModal, + modalData, + setOpenModal, handleFormChange, onPrimaryButtonClick, onCancelForm, @@ -55,6 +58,9 @@ export const FormPage: React.FC = React.memo(() => { errorLabels={formLabels?.errors} handleAddNew={handleAddNew} handleRemove={handleRemove} + openModal={openModal} + modalData={modalData} + setOpenModal={setOpenModal} /> ) : ( formState.message && {formState.message} diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index e2a3bec4..5ce96f75 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -1,4 +1,6 @@ import { useCallback, useEffect, useState } from "react"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; + import { Maybe } from "../../../utils/ts-utils"; import i18n from "../../../utils/i18n"; import { useAppContext } from "../../contexts/app-context"; @@ -22,10 +24,11 @@ import { } from "./incident-action/mapIncidentActionToInitialFormState"; import { useExistingEventTrackerTypes } from "../../contexts/existing-event-tracker-types-context"; import { useCheckWritePermission } from "../../hooks/useHasCurrentUserCaptureAccess"; -import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { usePerformanceOverview } from "../dashboard/usePerformanceOverview"; import { useIncidentActionPlan } from "../incident-action-plan/useIncidentActionPlan"; import { RiskAssessmentQuestionnaire } from "../../../domain/entities/risk-assessment/RiskAssessmentQuestionnaire"; +import { ModalData } from "../../components/form/Form"; +import { CasesDataSource } from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; export type GlobalMessage = { text: string; @@ -53,6 +56,9 @@ type State = { globalMessage: Maybe; formState: FormLoadState; isLoading: boolean; + openModal: boolean; + modalData?: ModalData; + setOpenModal: (open: boolean) => void; handleFormChange: (updatedField: FormFieldState) => void; onPrimaryButtonClick: () => void; onCancelForm: () => void; @@ -79,6 +85,8 @@ export function useForm(formType: FormType, id?: Id): State { const [isLoading, setIsLoading] = useState(false); const [formSectionsToDelete, setFormSectionsToDelete] = useState([]); const [entityData, setEntityData] = useState(); + const [openModal, setOpenModal] = useState(false); + const [modalData, setModalData] = useState(); const allDataPerformanceEvents = dataPerformanceOverview?.map( event => event.hazardType || event.suspectedDisease @@ -334,11 +342,68 @@ export function useForm(formType: FormType, id?: Id): State { [configurableForm] ); - const onPrimaryButtonClick = useCallback(() => { + const onSaveDiseaseOutbreakEvent = useCallback(() => { const { eventTrackerConfigurations } = configurations.selectableOptions; + if (formState.kind !== "loaded" || !configurableForm || !formState.data.isValid) return; - setIsLoading(true); + const formData = mapFormStateToEntityData( + formState.data, + currentUser.username, + configurableForm + ); + + if (formData.type === "disease-outbreak-event") { + setIsLoading(true); + compositionRoot.save.execute(formData, configurations, !!id, formSectionsToDelete).run( + diseaseOutbreakEventId => { + setIsLoading(false); + + if (diseaseOutbreakEventId && formData.entity) { + compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts + .execute( + diseaseOutbreakEventId, + formData.entity, + eventTrackerConfigurations.hazardTypes, + eventTrackerConfigurations.suspectedDiseases + ) + .run( + () => {}, + err => { + console.error({ err }); + } + ); + goTo(RouteName.EVENT_TRACKER, { + id: diseaseOutbreakEventId, + }); + setGlobalMessage({ + text: i18n.t(`Disease Outbreak saved successfully`), + type: "success", + }); + } + }, + err => { + setGlobalMessage({ + text: i18n.t(`Error saving disease outbreak: ${err.message}`), + type: "error", + }); + } + ); + } + }, [ + compositionRoot, + configurableForm, + configurations, + currentUser.username, + formSectionsToDelete, + formState, + goTo, + id, + ]); + + const onPrimaryButtonClick = useCallback(() => { + const { eventTrackerConfigurations } = configurations.selectableOptions; + if (formState.kind !== "loaded" || !configurableForm || !formState.data.isValid) return; const formData = mapFormStateToEntityData( formState.data, @@ -346,119 +411,143 @@ export function useForm(formType: FormType, id?: Id): State { configurableForm ); - compositionRoot.save.execute(formData, configurations, !!id, formSectionsToDelete).run( - diseaseOutbreakEventId => { - setIsLoading(false); - - switch (formData.type) { - case "disease-outbreak-event": - if (diseaseOutbreakEventId && formData.entity) { - compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts - .execute( - diseaseOutbreakEventId, - formData.entity, - eventTrackerConfigurations.hazardTypes, - eventTrackerConfigurations.suspectedDiseases - ) - .run( - () => {}, - err => { - console.error({ err }); - } - ); - goTo(RouteName.EVENT_TRACKER, { - id: diseaseOutbreakEventId, + const haveChangedCasesDataInDiseaseOutbreak = + !!id && + formData.type === "disease-outbreak-event" && + !formData.uploadedCasesDataFileId && + !!formData.uploadedCasesDataFile && + formData.entity?.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + if (haveChangedCasesDataInDiseaseOutbreak) { + setOpenModal(true); + setModalData({ + title: i18n.t("Warning"), + content: i18n.t( + "You have uploaded a new data cases file. This action will replace the current data of this disease outbreak event with the data of the file. Are you sure you want to continue?" + ), + cancelLabel: i18n.t("Cancel"), + confirmLabel: i18n.t("Save"), + onConfirm: onSaveDiseaseOutbreakEvent, + }); + } else { + setIsLoading(true); + compositionRoot.save.execute(formData, configurations, !!id, formSectionsToDelete).run( + diseaseOutbreakEventId => { + setIsLoading(false); + + switch (formData.type) { + case "disease-outbreak-event": + if (diseaseOutbreakEventId && formData.entity) { + compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts + .execute( + diseaseOutbreakEventId, + formData.entity, + eventTrackerConfigurations.hazardTypes, + eventTrackerConfigurations.suspectedDiseases + ) + .run( + () => {}, + err => { + console.error({ err }); + } + ); + goTo(RouteName.EVENT_TRACKER, { + id: diseaseOutbreakEventId, + }); + setGlobalMessage({ + text: i18n.t(`Disease Outbreak saved successfully`), + type: "success", + }); + } + break; + + case "risk-assessment-grading": + if (currentEventTracker?.id) + goTo(RouteName.EVENT_TRACKER, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Risk Assessment Grading saved successfully`), + type: "success", + }); + break; + case "risk-assessment-summary": + goTo(RouteName.CREATE_FORM, { + formType: "risk-assessment-questionnaire", }); setGlobalMessage({ - text: i18n.t(`Disease Outbreak saved successfully`), + text: i18n.t(`Risk Assessment Summary saved successfully`), type: "success", }); - } - break; - - case "risk-assessment-grading": - if (currentEventTracker?.id) - goTo(RouteName.EVENT_TRACKER, { - id: currentEventTracker?.id, + break; + case "risk-assessment-questionnaire": + goTo(RouteName.CREATE_FORM, { + formType: "risk-assessment-grading", }); - setGlobalMessage({ - text: i18n.t(`Risk Assessment Grading saved successfully`), - type: "success", - }); - break; - case "risk-assessment-summary": - goTo(RouteName.CREATE_FORM, { - formType: "risk-assessment-questionnaire", - }); - setGlobalMessage({ - text: i18n.t(`Risk Assessment Summary saved successfully`), - type: "success", - }); - break; - case "risk-assessment-questionnaire": - goTo(RouteName.CREATE_FORM, { - formType: "risk-assessment-grading", - }); - setGlobalMessage({ - text: i18n.t(`Risk Assessment Questionnaire saved successfully`), - type: "success", - }); - break; - case "incident-action-plan": - goTo(RouteName.CREATE_FORM, { - formType: "incident-response-actions", - }); - setGlobalMessage({ - text: i18n.t(`Incident Action Plan saved successfully`), - type: "success", - }); - break; - case "incident-response-actions": - if (currentEventTracker?.id) - goTo(RouteName.INCIDENT_ACTION_PLAN, { - id: currentEventTracker?.id, + setGlobalMessage({ + text: i18n.t(`Risk Assessment Questionnaire saved successfully`), + type: "success", }); - setGlobalMessage({ - text: i18n.t(`Incident Response Actions saved successfully`), - type: "success", - }); - break; - case "incident-response-action": - if (currentEventTracker?.id) - goTo(RouteName.INCIDENT_ACTION_PLAN, { - id: currentEventTracker?.id, + break; + case "incident-action-plan": + goTo(RouteName.CREATE_FORM, { + formType: "incident-response-actions", }); - setGlobalMessage({ - text: i18n.t(`Incident Response Actions saved successfully`), - type: "success", - }); - break; - case "incident-management-team-member-assignment": - if (currentEventTracker?.id) - goTo(RouteName.IM_TEAM_BUILDER, { - id: currentEventTracker?.id, + setGlobalMessage({ + text: i18n.t(`Incident Action Plan saved successfully`), + type: "success", }); - setGlobalMessage({ - text: i18n.t(`Incident Management Team Member saved successfully`), - type: "success", - }); - break; + break; + case "incident-response-actions": + if (currentEventTracker?.id) + goTo(RouteName.INCIDENT_ACTION_PLAN, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Response Actions saved successfully`), + type: "success", + }); + break; + case "incident-response-action": + if (currentEventTracker?.id) + goTo(RouteName.INCIDENT_ACTION_PLAN, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Response Actions saved successfully`), + type: "success", + }); + break; + case "incident-management-team-member-assignment": + if (currentEventTracker?.id) + goTo(RouteName.IM_TEAM_BUILDER, { + id: currentEventTracker?.id, + }); + setGlobalMessage({ + text: i18n.t(`Incident Management Team Member saved successfully`), + type: "success", + }); + break; + } + }, + err => { + setGlobalMessage({ + text: i18n.t(`Error saving disease outbreak: ${err.message}`), + type: "error", + }); } - }, - err => { - setGlobalMessage({ - text: i18n.t(`Error saving disease outbreak: ${err.message}`), - type: "error", - }); - } - ); + ); + } }, [ configurations, formState, configurableForm, currentUser.username, - compositionRoot, id, + onSaveDiseaseOutbreakEvent, + compositionRoot.save, + compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts, formSectionsToDelete, currentEventTracker?.id, goTo, @@ -495,6 +584,9 @@ export function useForm(formType: FormType, id?: Id): State { globalMessage, formState, isLoading, + openModal, + modalData, + setOpenModal, handleFormChange, onPrimaryButtonClick, onCancelForm, From 14b5878408537431019bf26093081c62e69b5479 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Fri, 20 Dec 2024 09:01:36 +0100 Subject: [PATCH 12/21] Update translations --- i18n/en.pot | 13 +++++++++++-- i18n/es.po | 11 ++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 08f217d5..f0ecf0e0 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-12-18T17:43:51.092Z\n" -"PO-Revision-Date: 2024-12-18T17:43:51.092Z\n" +"POT-Creation-Date: 2024-12-19T16:37:02.900Z\n" +"PO-Revision-Date: 2024-12-19T16:37:02.900Z\n" msgid "Low" msgstr "" @@ -230,6 +230,15 @@ msgstr "" msgid "Disease Outbreak saved successfully" msgstr "" +msgid "Warning" +msgstr "" + +msgid "" +"You have uploaded a new data cases file. This action will replace the " +"current data of this disease outbreak event with the data of the file. Are " +"you sure you want to continue?" +msgstr "" + msgid "Risk Assessment Grading saved successfully" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 86c3980b..5efb2f32 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-12-18T17:43:51.092Z\n" +"POT-Creation-Date: 2024-12-19T16:37:02.900Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -229,6 +229,15 @@ msgstr "" msgid "Disease Outbreak saved successfully" msgstr "" +msgid "Warning" +msgstr "" + +msgid "" +"You have uploaded a new data cases file. This action will replace the " +"current data of this disease outbreak event with the data of the file. Are " +"you sure you want to continue?" +msgstr "" + msgid "Risk Assessment Grading saved successfully" msgstr "" From 54c79571c8f6c959a97ff0b49fe3aedd4d22e2e8 Mon Sep 17 00:00:00 2001 From: Ana Garcia Date: Mon, 23 Dec 2024 12:38:00 +0100 Subject: [PATCH 13/21] Add button to edit case data from event summary section --- src/domain/entities/ConfigurableForm.ts | 2 +- .../usecases/GetConfigurableFormUseCase.ts | 10 +- src/domain/usecases/SaveEntityUseCase.ts | 3 +- .../GetDiseaseOutbreakConfigurableForm.ts | 3 +- .../form-summary/ActionPlanFormSummary.tsx | 2 +- .../form-summary/EventTrackerFormSummary.tsx | 81 +++--- src/webapp/components/section/Section.tsx | 11 +- .../hooks/useHasCurrentUserCaptureAccess.ts | 3 + src/webapp/hooks/useRoutes.ts | 1 + .../pages/event-tracker/EventTrackerPage.tsx | 7 +- src/webapp/pages/form-page/FormPage.tsx | 1 + ...pDiseaseOutbreakEventToInitialFormState.ts | 58 ++++ .../mapFormStateToDiseaseOutbreakEvent.ts | 260 ++++++++++++++++++ .../pages/form-page/mapEntityToFormState.ts | 1 + .../form-page/mapFormStateToEntityData.ts | 193 +------------ src/webapp/pages/form-page/useForm.ts | 33 ++- .../ResponseActionTable.tsx | 2 +- .../incident-action-plan/TeamSection.tsx | 2 +- .../IMTeamBuilderPage.tsx | 2 +- 19 files changed, 431 insertions(+), 244 deletions(-) create mode 100644 src/webapp/pages/form-page/disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent.ts diff --git a/src/domain/entities/ConfigurableForm.ts b/src/domain/entities/ConfigurableForm.ts index e227b7af..b87890d3 100644 --- a/src/domain/entities/ConfigurableForm.ts +++ b/src/domain/entities/ConfigurableForm.ts @@ -72,7 +72,7 @@ type BaseFormData = { type: FormType; }; export type DiseaseOutbreakEventFormData = BaseFormData & { - type: "disease-outbreak-event"; + type: "disease-outbreak-event" | "disease-outbreak-event-case-data"; entity: Maybe; options: DiseaseOutbreakEventOptions; orgUnits: OrgUnit[]; diff --git a/src/domain/usecases/GetConfigurableFormUseCase.ts b/src/domain/usecases/GetConfigurableFormUseCase.ts index 0e318d18..59dd1917 100644 --- a/src/domain/usecases/GetConfigurableFormUseCase.ts +++ b/src/domain/usecases/GetConfigurableFormUseCase.ts @@ -45,8 +45,14 @@ export class GetConfigurableFormUseCase { const { formType, eventTrackerDetails, configurations, id, responseActionId } = options; switch (formType) { - case "disease-outbreak-event": { - return getDiseaseOutbreakConfigurableForm(this.options, configurations, id); + case "disease-outbreak-event": + case "disease-outbreak-event-case-data": { + return getDiseaseOutbreakConfigurableForm( + this.options, + configurations, + formType, + id + ); } case "risk-assessment-grading": if (!eventTrackerDetails) diff --git a/src/domain/usecases/SaveEntityUseCase.ts b/src/domain/usecases/SaveEntityUseCase.ts index 7570043f..42987a2d 100644 --- a/src/domain/usecases/SaveEntityUseCase.ts +++ b/src/domain/usecases/SaveEntityUseCase.ts @@ -39,7 +39,8 @@ export class SaveEntityUseCase { ): FutureData { if (!formData || !formData.entity) return Future.error(new Error("No form data found")); switch (formData.type) { - case "disease-outbreak-event": { + case "disease-outbreak-event": + case "disease-outbreak-event-case-data": { const diseaseOutbreakEvent: DiseaseOutbreakEvent = new DiseaseOutbreakEvent({ ...formData.entity, diff --git a/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts b/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts index 35913a1b..c740753d 100644 --- a/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts +++ b/src/domain/usecases/utils/disease-outbreak/GetDiseaseOutbreakConfigurableForm.ts @@ -19,13 +19,14 @@ export function getDiseaseOutbreakConfigurableForm( casesFileRepository: CasesFileRepository; }, configurations: Configurations, + formType: "disease-outbreak-event" | "disease-outbreak-event-case-data", id?: Id ): FutureData { const { rules, labels } = getEventTrackerLabelsRules(); return options.casesFileRepository.getTemplate().flatMap(casesFileTemplate => { const diseaseOutbreakForm: DiseaseOutbreakEventFormData = { - type: "disease-outbreak-event", + type: formType, entity: undefined, uploadedCasesDataFile: undefined, uploadedCasesDataFileId: undefined, diff --git a/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx b/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx index d949caa0..f9328a96 100644 --- a/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx +++ b/src/webapp/components/form/form-summary/ActionPlanFormSummary.tsx @@ -59,7 +59,7 @@ export const ActionPlanFormSummary: React.FC = React
diff --git a/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx b/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx index 168d69b7..653ac0a1 100644 --- a/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx +++ b/src/webapp/components/form/form-summary/EventTrackerFormSummary.tsx @@ -1,32 +1,41 @@ import React, { useCallback, useEffect } from "react"; import styled from "styled-components"; import i18n from "@eyeseetea/d2-ui-components/locales"; +import { EditOutlined } from "@material-ui/icons"; +import { CheckOutlined } from "@material-ui/icons"; +import BackupIcon from "@material-ui/icons/Backup"; +import { useSnackbar } from "@eyeseetea/d2-ui-components"; + import { Section } from "../../section/Section"; import { Box, Button, Typography } from "@material-ui/core"; import { UserCard } from "../../user-selector/UserCard"; import { RouteName, useRoutes } from "../../../hooks/useRoutes"; -import { EditOutlined } from "@material-ui/icons"; -import { CheckOutlined } from "@material-ui/icons"; import { Loader } from "../../loader/Loader"; -import { useSnackbar } from "@eyeseetea/d2-ui-components"; import { FormSummaryData } from "../../../pages/event-tracker/useDiseaseOutbreakEvent"; import { Maybe } from "../../../../utils/ts-utils"; -import { FormType } from "../../../pages/form-page/FormPage"; import { Id } from "../../../../domain/entities/Ref"; import { GlobalMessage } from "../../../pages/form-page/useForm"; export type EventTrackerFormSummaryProps = { id: Id; - formType: FormType; + diseaseOutbreakFormType: "disease-outbreak-event"; + diseaseOutbreakCaseDataFormType: "disease-outbreak-event-case-data"; formSummary: Maybe; globalMessage: Maybe; - onOpenModal: () => void; + onCompleteClick: () => void; }; const ROW_COUNT = 3; export const EventTrackerFormSummary: React.FC = React.memo(props => { - const { id, formType, formSummary, onOpenModal: onCompleteClick, globalMessage } = props; + const { + id, + diseaseOutbreakCaseDataFormType, + diseaseOutbreakFormType, + formSummary, + onCompleteClick, + globalMessage, + } = props; const { goTo } = useRoutes(); const snackbar = useSnackbar(); @@ -38,29 +47,40 @@ export const EventTrackerFormSummary: React.FC = R }, [globalMessage, goTo, snackbar]); const onEditClick = useCallback(() => { - goTo(RouteName.EDIT_FORM, { formType: formType, id: id }); - }, [formType, goTo, id]); + goTo(RouteName.EDIT_FORM, { formType: diseaseOutbreakFormType, id: id }); + }, [diseaseOutbreakFormType, goTo, id]); - const editButton = ( - - ); + const onEditCaseDataClick = useCallback(() => { + goTo(RouteName.EDIT_FORM, { formType: diseaseOutbreakCaseDataFormType, id: id }); + }, [diseaseOutbreakCaseDataFormType, goTo, id]); - const completeButton = ( - + const headerButtons = ( + <> + + + + ); const getSummaryColumn = useCallback((index: number, label: string, value: string) => { @@ -79,8 +99,7 @@ export const EventTrackerFormSummary: React.FC = R
@@ -128,7 +147,7 @@ export const EventTrackerFormSummary: React.FC = R const SummaryContainer = styled.div` display: flex; flex-wrap: wrap; - width: max-content; + width: 100%; align-items: flex-start; margin-top: 0rem; @media (max-width: 1200px) { diff --git a/src/webapp/components/section/Section.tsx b/src/webapp/components/section/Section.tsx index ff3a0495..9d77002f 100644 --- a/src/webapp/components/section/Section.tsx +++ b/src/webapp/components/section/Section.tsx @@ -8,8 +8,7 @@ type SectionProps = { title?: string; lastUpdated?: string; children: React.ReactNode; - headerButton?: React.ReactNode; - secondaryHeaderButton?: React.ReactNode; + headerButtons?: React.ReactNode; hasSeparator?: boolean; titleVariant?: "primary" | "secondary"; }; @@ -18,8 +17,7 @@ export const Section: React.FC = React.memo( ({ title = "", lastUpdated = "", - headerButton, - secondaryHeaderButton, + headerButtons, hasSeparator = false, children, titleVariant = "primary", @@ -42,10 +40,7 @@ export const Section: React.FC = React.memo( ) : null} - - {headerButton ?
{headerButton}
: null} - {secondaryHeaderButton ?
{secondaryHeaderButton}
: null} -
+ {headerButtons ? {headerButtons} : null} {children} diff --git a/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts b/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts index b1f5cfa1..4e91bb8f 100644 --- a/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts +++ b/src/webapp/hooks/useHasCurrentUserCaptureAccess.ts @@ -15,6 +15,9 @@ export function useCheckWritePermission(formType: FormType) { case "disease-outbreak-event": snackbar.error("You do not have permission to create/edit events"); break; + case "disease-outbreak-event-case-data": + snackbar.error("You do not have permission to edit historical case data"); + break; case "incident-management-team-member-assignment": snackbar.error( "You do not have permission to create/edit IM team member assignments" diff --git a/src/webapp/hooks/useRoutes.ts b/src/webapp/hooks/useRoutes.ts index 9b50504f..f78cecf6 100644 --- a/src/webapp/hooks/useRoutes.ts +++ b/src/webapp/hooks/useRoutes.ts @@ -16,6 +16,7 @@ export enum RouteName { const formTypes = [ "disease-outbreak-event", + "disease-outbreak-event-case-data", "risk-assessment-grading", "risk-assessment-summary", "risk-assessment-questionnaire", diff --git a/src/webapp/pages/event-tracker/EventTrackerPage.tsx b/src/webapp/pages/event-tracker/EventTrackerPage.tsx index 5bc85b86..3d81106c 100644 --- a/src/webapp/pages/event-tracker/EventTrackerPage.tsx +++ b/src/webapp/pages/event-tracker/EventTrackerPage.tsx @@ -88,9 +88,10 @@ export const EventTrackerPage: React.FC = React.memo(() => {
@@ -121,7 +122,7 @@ export const EventTrackerPage: React.FC = React.memo(() => {
, + allFields: FormFieldState[] +): DiseaseOutbreakEvent { + const diseaseOutbreakEventEditableData = { + name: getStringFieldValue(diseaseOutbreakEventFieldIds.name, allFields), + dataSource: getStringFieldValue( + diseaseOutbreakEventFieldIds.dataSource, + allFields + ) as DataSource, + hazardType: getStringFieldValue( + diseaseOutbreakEventFieldIds.hazardType, + allFields + ) as HazardType, + mainSyndromeCode: getStringFieldValue( + diseaseOutbreakEventFieldIds.mainSyndromeCode, + allFields + ), + suspectedDiseaseCode: getStringFieldValue( + diseaseOutbreakEventFieldIds.suspectedDiseaseCode, + allFields + ), + notificationSourceCode: getStringFieldValue( + diseaseOutbreakEventFieldIds.notificationSourceCode, + allFields + ), + areasAffectedProvinceIds: getMultipleOptionsFieldValue( + diseaseOutbreakEventFieldIds.areasAffectedProvinceIds, + allFields + ), + areasAffectedDistrictIds: getMultipleOptionsFieldValue( + diseaseOutbreakEventFieldIds.areasAffectedDistrictIds, + allFields + ), + incidentStatus: getStringFieldValue( + diseaseOutbreakEventFieldIds.incidentStatus, + allFields + ) as NationalIncidentStatus, + emerged: { + date: getDateFieldValue(diseaseOutbreakEventFieldIds.emergedDate, allFields) as Date, + narrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.emergedNarrative, + allFields + ), + }, + detected: { + date: getDateFieldValue(diseaseOutbreakEventFieldIds.detectedDate, allFields) as Date, + narrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.detectedNarrative, + allFields + ), + }, + notified: { + date: getDateFieldValue(diseaseOutbreakEventFieldIds.notifiedDate, allFields) as Date, + narrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.notifiedNarrative, + allFields + ), + }, + earlyResponseActions: { + initiateInvestigation: getDateFieldValue( + diseaseOutbreakEventFieldIds.initiateInvestigation, + allFields + ) as Date, + conductEpidemiologicalAnalysis: getDateFieldValue( + diseaseOutbreakEventFieldIds.conductEpidemiologicalAnalysis, + allFields + ) as Date, + laboratoryConfirmation: getDateFieldValue( + diseaseOutbreakEventFieldIds.laboratoryConfirmation, + allFields + ) as Date, + appropriateCaseManagement: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.appropriateCaseManagementDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.appropriateCaseManagementNA, + allFields + ), + }, + initiatePublicHealthCounterMeasures: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresNA, + allFields + ), + }, + initiateRiskCommunication: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.initiateRiskCommunicationDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.initiateRiskCommunicationNA, + allFields + ), + }, + establishCoordination: { + date: getDateFieldValue( + diseaseOutbreakEventFieldIds.establishCoordinationDate, + allFields + ) as Date, + na: getBooleanFieldValue( + diseaseOutbreakEventFieldIds.establishCoordinationNa, + allFields + ), + }, + responseNarrative: getStringFieldValue( + diseaseOutbreakEventFieldIds.responseNarrative, + allFields + ), + }, + incidentManagerName: getStringFieldValue( + diseaseOutbreakEventFieldIds.incidentManagerName, + allFields + ), + notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), + casesDataSource: getStringFieldValue( + diseaseOutbreakEventFieldIds.casesDataSource, + allFields + ) as CasesDataSource, + }; + + const isCasesDataUserDefined = + diseaseOutbreakEventEditableData.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + const uploadedCasesSheetData = isCasesDataUserDefined + ? getFieldFileDataById(diseaseOutbreakEventFieldIds.casesDataFile, allFields) + : undefined; + + const hasCasesDataChange = isCasesDataUserDefined && uploadedCasesSheetData; + + const casesData = hasCasesDataChange + ? getCaseDataFromField(uploadedCasesSheetData, currentUserName) + : diseaseOutbreakEvent?.uploadedCasesData; + + const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { + id: diseaseOutbreakEvent?.id || "", + status: diseaseOutbreakEvent?.status || "ACTIVE", + created: diseaseOutbreakEvent?.created, + lastUpdated: diseaseOutbreakEvent?.lastUpdated, + createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, + ...diseaseOutbreakEventEditableData, + }; + const newDiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + uploadedCasesData: undefined, + + // NOTICE: Not needed but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); + + return casesData + ? newDiseaseOutbreakEvent.addUploadedCasesData(casesData) + : newDiseaseOutbreakEvent; +} + +function getDiseaseOutbreakEventFromDiseaseOutbreakCaseDataForm( + currentUserName: string, + diseaseOutbreakEvent: Maybe, + allFields: FormFieldState[] +): DiseaseOutbreakEvent { + if (!diseaseOutbreakEvent) { + throw new Error("Disease Outbreak Event is required"); + } + + const isCasesDataUserDefined = + diseaseOutbreakEvent.casesDataSource === + CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; + + const uploadedCasesSheetData = isCasesDataUserDefined + ? getFieldFileDataById(diseaseOutbreakEventFieldIds.casesDataFile, allFields) + : undefined; + + const hasCasesDataChange = isCasesDataUserDefined && uploadedCasesSheetData; + + const casesData = hasCasesDataChange + ? getCaseDataFromField(uploadedCasesSheetData, currentUserName) + : diseaseOutbreakEvent.uploadedCasesData; + + const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { + ...diseaseOutbreakEvent, + }; + const newDiseaseOutbreakEvent = new DiseaseOutbreakEvent({ + ...diseaseOutbreakEventBase, + uploadedCasesData: undefined, + + // NOTICE: Not needed but required + createdBy: undefined, + mainSyndrome: undefined, + suspectedDisease: undefined, + notificationSource: undefined, + incidentManager: undefined, + riskAssessment: undefined, + incidentActionPlan: undefined, + incidentManagementTeam: undefined, + }); + + return casesData + ? newDiseaseOutbreakEvent.addUploadedCasesData(casesData) + : newDiseaseOutbreakEvent; +} diff --git a/src/webapp/pages/form-page/mapEntityToFormState.ts b/src/webapp/pages/form-page/mapEntityToFormState.ts index 386ecb7f..ba181d75 100644 --- a/src/webapp/pages/form-page/mapEntityToFormState.ts +++ b/src/webapp/pages/form-page/mapEntityToFormState.ts @@ -31,6 +31,7 @@ export function mapEntityToFormState(options: { switch (configurableForm.type) { case "disease-outbreak-event": + case "disease-outbreak-event-case-data": return mapDiseaseOutbreakEventToInitialFormState( configurableForm, editMode ?? false, diff --git a/src/webapp/pages/form-page/mapFormStateToEntityData.ts b/src/webapp/pages/form-page/mapFormStateToEntityData.ts index e158dc6c..c6194feb 100644 --- a/src/webapp/pages/form-page/mapFormStateToEntityData.ts +++ b/src/webapp/pages/form-page/mapFormStateToEntityData.ts @@ -1,22 +1,10 @@ -import { - CasesDataSource, - DataSource, - DiseaseOutbreakEvent, - DiseaseOutbreakEventBaseAttrs, - HazardType, - NationalIncidentStatus, -} from "../../../domain/entities/disease-outbreak-event/DiseaseOutbreakEvent"; import { FormState } from "../../components/form/FormState"; import { diseaseOutbreakEventFieldIds } from "./disease-outbreak-event/mapDiseaseOutbreakEventToInitialFormState"; import { FormFieldState, getAllFieldsFromSections, - getBooleanFieldValue, - getDateFieldValue, - getFieldFileDataById, getFieldFileIdById, getFileFieldValue, - getMultipleOptionsFieldValue, getStringFieldValue, } from "../../components/form/FormFieldsState"; import { @@ -31,7 +19,6 @@ import { ResponseActionFormData, SingleResponseActionFormData, } from "../../../domain/entities/ConfigurableForm"; -import { Maybe } from "../../../utils/ts-utils"; import { RiskAssessmentGrading } from "../../../domain/entities/risk-assessment/RiskAssessmentGrading"; import { riskAssessmentGradingCodes, @@ -58,7 +45,7 @@ import { import { TeamMember } from "../../../domain/entities/incident-management-team/TeamMember"; import { TEAM_ROLE_FIELD_ID } from "./incident-management-team-member-assignment/mapIncidentManagementTeamMemberToInitialFormState"; import { incidentManagementTeamBuilderCodesWithoutRoles } from "../../../data/repositories/consts/IncidentManagementTeamBuilderConstants"; -import { getCaseDataFromField } from "./disease-outbreak-event/CaseDataFileFieldHelper"; +import { mapFormStateToDiseaseOutbreakEvent } from "./disease-outbreak-event/mapFormStateToDiseaseOutbreakEvent"; export function mapFormStateToEntityData( formState: FormState, @@ -66,11 +53,12 @@ export function mapFormStateToEntityData( formData: ConfigurableForm ): ConfigurableForm { switch (formData.type) { - case "disease-outbreak-event": { + case "disease-outbreak-event": + case "disease-outbreak-event-case-data": { const dieaseEntity = mapFormStateToDiseaseOutbreakEvent( formState, currentUserName, - formData.entity + formData ); const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); @@ -161,179 +149,6 @@ export function mapFormStateToEntityData( } } -function mapFormStateToDiseaseOutbreakEvent( - formState: FormState, - currentUserName: string, - diseaseOutbreakEvent: Maybe -): DiseaseOutbreakEvent { - const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); - - const diseaseOutbreakEventEditableData = { - name: getStringFieldValue(diseaseOutbreakEventFieldIds.name, allFields), - dataSource: getStringFieldValue( - diseaseOutbreakEventFieldIds.dataSource, - allFields - ) as DataSource, - hazardType: getStringFieldValue( - diseaseOutbreakEventFieldIds.hazardType, - allFields - ) as HazardType, - mainSyndromeCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.mainSyndromeCode, - allFields - ), - suspectedDiseaseCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.suspectedDiseaseCode, - allFields - ), - notificationSourceCode: getStringFieldValue( - diseaseOutbreakEventFieldIds.notificationSourceCode, - allFields - ), - areasAffectedProvinceIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedProvinceIds, - allFields - ), - areasAffectedDistrictIds: getMultipleOptionsFieldValue( - diseaseOutbreakEventFieldIds.areasAffectedDistrictIds, - allFields - ), - incidentStatus: getStringFieldValue( - diseaseOutbreakEventFieldIds.incidentStatus, - allFields - ) as NationalIncidentStatus, - emerged: { - date: getDateFieldValue(diseaseOutbreakEventFieldIds.emergedDate, allFields) as Date, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.emergedNarrative, - allFields - ), - }, - detected: { - date: getDateFieldValue(diseaseOutbreakEventFieldIds.detectedDate, allFields) as Date, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.detectedNarrative, - allFields - ), - }, - notified: { - date: getDateFieldValue(diseaseOutbreakEventFieldIds.notifiedDate, allFields) as Date, - narrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.notifiedNarrative, - allFields - ), - }, - earlyResponseActions: { - initiateInvestigation: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiateInvestigation, - allFields - ) as Date, - conductEpidemiologicalAnalysis: getDateFieldValue( - diseaseOutbreakEventFieldIds.conductEpidemiologicalAnalysis, - allFields - ) as Date, - laboratoryConfirmation: getDateFieldValue( - diseaseOutbreakEventFieldIds.laboratoryConfirmation, - allFields - ) as Date, - appropriateCaseManagement: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.appropriateCaseManagementNA, - allFields - ), - }, - initiatePublicHealthCounterMeasures: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiatePublicHealthCounterMeasuresNA, - allFields - ), - }, - initiateRiskCommunication: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.initiateRiskCommunicationNA, - allFields - ), - }, - establishCoordination: { - date: getDateFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationDate, - allFields - ) as Date, - na: getBooleanFieldValue( - diseaseOutbreakEventFieldIds.establishCoordinationNa, - allFields - ), - }, - responseNarrative: getStringFieldValue( - diseaseOutbreakEventFieldIds.responseNarrative, - allFields - ), - }, - incidentManagerName: getStringFieldValue( - diseaseOutbreakEventFieldIds.incidentManagerName, - allFields - ), - notes: getStringFieldValue(diseaseOutbreakEventFieldIds.notes, allFields), - casesDataSource: getStringFieldValue( - diseaseOutbreakEventFieldIds.casesDataSource, - allFields - ) as CasesDataSource, - }; - - const isCasesDataUserDefined = - diseaseOutbreakEventEditableData.casesDataSource === - CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; - - const uploadedCasesSheetData = isCasesDataUserDefined - ? getFieldFileDataById(diseaseOutbreakEventFieldIds.casesDataFile, allFields) - : undefined; - - const hasCasesDataChange = isCasesDataUserDefined && uploadedCasesSheetData; - - const casesData = hasCasesDataChange - ? getCaseDataFromField(uploadedCasesSheetData, currentUserName) - : diseaseOutbreakEvent?.uploadedCasesData; - - const diseaseOutbreakEventBase: DiseaseOutbreakEventBaseAttrs = { - id: diseaseOutbreakEvent?.id || "", - status: diseaseOutbreakEvent?.status || "ACTIVE", - created: diseaseOutbreakEvent?.created, - lastUpdated: diseaseOutbreakEvent?.lastUpdated, - createdByName: diseaseOutbreakEvent?.createdByName || currentUserName, - ...diseaseOutbreakEventEditableData, - }; - const newDiseaseOutbreakEvent = new DiseaseOutbreakEvent({ - ...diseaseOutbreakEventBase, - uploadedCasesData: undefined, - - // NOTICE: Not needed but required - createdBy: undefined, - mainSyndrome: undefined, - suspectedDisease: undefined, - notificationSource: undefined, - incidentManager: undefined, - riskAssessment: undefined, - incidentActionPlan: undefined, - incidentManagementTeam: undefined, - }); - - return casesData - ? newDiseaseOutbreakEvent.addUploadedCasesData(casesData) - : newDiseaseOutbreakEvent; -} - function mapFormStateToRiskAssessmentGrading(formState: FormState): RiskAssessmentGrading { const allFields: FormFieldState[] = getAllFieldsFromSections(formState.sections); diff --git a/src/webapp/pages/form-page/useForm.ts b/src/webapp/pages/form-page/useForm.ts index 5ce96f75..061939ed 100644 --- a/src/webapp/pages/form-page/useForm.ts +++ b/src/webapp/pages/form-page/useForm.ts @@ -353,13 +353,20 @@ export function useForm(formType: FormType, id?: Id): State { configurableForm ); - if (formData.type === "disease-outbreak-event") { + if ( + formData.type === "disease-outbreak-event" || + formData.type === "disease-outbreak-event-case-data" + ) { setIsLoading(true); compositionRoot.save.execute(formData, configurations, !!id, formSectionsToDelete).run( diseaseOutbreakEventId => { setIsLoading(false); - if (diseaseOutbreakEventId && formData.entity) { + if ( + diseaseOutbreakEventId && + formData.entity && + formData.type === "disease-outbreak-event" + ) { compositionRoot.diseaseOutbreakEvent.mapDiseaseOutbreakEventToAlerts .execute( diseaseOutbreakEventId, @@ -380,11 +387,26 @@ export function useForm(formType: FormType, id?: Id): State { text: i18n.t(`Disease Outbreak saved successfully`), type: "success", }); + } else if ( + diseaseOutbreakEventId && + formData.type === "disease-outbreak-event-case-data" + ) { + goTo(RouteName.EVENT_TRACKER, { + id: diseaseOutbreakEventId, + }); + setGlobalMessage({ + text: i18n.t(`Disease outbreak case data saved successfully`), + type: "success", + }); } }, err => { setGlobalMessage({ - text: i18n.t(`Error saving disease outbreak: ${err.message}`), + text: i18n.t( + formData.type === "disease-outbreak-event-case-data" + ? `Error saving disease outbreak case data: ${err.message}` + : `Error saving disease outbreak: ${err.message}` + ), type: "error", }); } @@ -419,7 +441,10 @@ export function useForm(formType: FormType, id?: Id): State { formData.entity?.casesDataSource === CasesDataSource.RTSL_ZEB_OS_CASE_DATA_SOURCE_USER_DEF; - if (haveChangedCasesDataInDiseaseOutbreak) { + if ( + haveChangedCasesDataInDiseaseOutbreak || + formData.type === "disease-outbreak-event-case-data" + ) { setOpenModal(true); setModalData({ title: i18n.t("Warning"), diff --git a/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx b/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx index f1d6b9e0..5418cc3b 100644 --- a/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx +++ b/src/webapp/pages/incident-action-plan/ResponseActionTable.tsx @@ -40,7 +40,7 @@ export const ResponseActionTable: React.FC = React.mem
= React.memo(props => {
{
{selectedHierarchyItemIds.length > 1 ? null : ( - + + {isCasesDataUserDefined ? ( + + ) : null} +