diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..72446f4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/README.md b/README.md index fc043e6..2d3fcf9 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,6 @@ ## Introduction -_d2-reports_ than can be used as an standalone DHIS2 webapp or an standard HTML report (App: Reports). DHIS2 versions tested: 2.34. - -## Reports - -### NHWA Comments - -This report shows data values for data sets `NHWA Module ...`. There are two kinds of data values displayed in the report table: - -1. Data values that have comments. -2. Data values related pairs (value/comment), which are rendered as a single row. The pairing criteria is: - - - Comment data element `NHWA_Comment of Abc`. - - Value data element: `NHWA_Abc`. - -The API endpoint `/dataValueSets` does not provide all the features we need, so we use a custom SQL View instead. +Data Approval App ## Initial setup @@ -30,18 +16,28 @@ Start the development server at `http://localhost:8082` using `https://play.dhis $ PORT=8082 REACT_APP_DHIS2_BASE_URL="https://play.dhis2.org/2.34" yarn start ``` -## Deploy +## Generate sql view for a dataSet + +Script to generate necessary sqlViews for dataSets -Create an standard report: +if you want to persist the sqlViews to DHIS2 please configure the following variables in your `.env` file: ``` -$ yarn build-report # Creates dist/index.html -$ yarn build--metadata -u 'user:pass' --url http://dhis2-server.org # Creates dist/metadata.json (key is a particular report group, e.g. nhwa) -$ yarn post--metadata -u 'user:pass' --url http://dhis2-server.org # Posts dist/metadata.json (key is a particular report group, e.g. nhwa) +REACT_APP_DHIS2_BASE_URL=http://localhost:8080 +REACT_APP_DHIS2_AUTH='admin:district' ``` -Create an standalone DHIS2 webapp app: +run the script +```shell +yarn run generate-sqlviews \ +--dataSet MY_DS_CODE \ +--dataElement-submission DATAELEMENT_CODE_SUBMISSION-APVD \ +--dataElement-approval DATAELEMENT_CODE_APPROVAL_DATE-APVD \ +--persist dhis ``` -$ yarn build-webapp # Creates dist/d2-reports.zip -``` + +- dataSet: dataSet code of the original dataSet +- dataElement-submission: dataElement code where the submission date is going to be saved (this must be in the APPROVAL dataSet) +- dataElement-approval: dataElement code where the approval date is going to be saved (this must be in the APPROVAL dataSet) +- persist: save sqlViews to `dhis` or `disk` diff --git a/i18n/en.pot b/i18n/en.pot index 5434376..2f5be96 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: 2025-10-10T16:43:16.465Z\n" -"PO-Revision-Date: 2025-10-10T16:43:16.465Z\n" +"POT-Creation-Date: 2026-01-07T15:43:58.004Z\n" +"PO-Revision-Date: 2026-01-07T15:43:58.004Z\n" msgid "0" msgstr "" @@ -17,15 +17,78 @@ msgstr "" msgid "Submitting dataset..." msgstr "" -msgid "" +msgid "Cannot be blank: {{fieldName}}" msgstr "" -msgid "LOG OUT" +msgid "The same value is not allowed for: {{fieldName}}" +msgstr "" + +msgid "Please complete all required fields." +msgstr "" + +msgid "DataSet" +msgstr "" + +msgid "DataSet Approval" +msgstr "" + +msgid "Submit Date DataElement" +msgstr "" + +msgid "Approval Date DataElement" +msgstr "" + +msgid "Data Source" +msgstr "" + +msgid "Old Periods Data Source" +msgstr "" + +msgid "Submit also approves the dataSet" +msgstr "" + +msgid "Revoke also marks dataSet as incomplete" +msgstr "" + +msgid "Edit \"{{action}}\" Permissions" +msgstr "" + +msgid "Save" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "DataSet Code" +msgstr "" + +msgid "DataSet Approval Code" +msgstr "" + +msgid "DataElement Submission Date" +msgstr "" + +msgid "DataElement Approval Date" +msgstr "" + +msgid "Edit" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "" msgstr "" msgid "Close" msgstr "" +msgid "Click on the ID to select the row" +msgstr "" + +msgid "LOG OUT" +msgstr "" + msgid "Loading..." msgstr "" @@ -50,9 +113,6 @@ msgstr "" msgid "Confirm" msgstr "" -msgid "Cancel" -msgstr "" - msgid "" "This action will delete the stored data and process all the Indicators and " "Program Indicators from this DHIS2 instance." @@ -64,6 +124,21 @@ msgstr "" msgid "Are you sure?" msgstr "" +msgid "Delete DataSet Configuration" +msgstr "" + +msgid "Are you sure you want to delete this DataSet Configuration?" +msgstr "" + +msgid "Edit Configuration" +msgstr "" + +msgid "Add Configuration" +msgstr "" + +msgid "DataSet Configurations" +msgstr "" + msgid "Metadata Admin Report" msgstr "" @@ -395,9 +470,6 @@ msgstr "" msgid "GLASS Admin Maintenance Report" msgstr "" -msgid "Delete" -msgstr "" - msgid "File" msgstr "" @@ -616,6 +688,12 @@ msgstr "" msgid "Data Approval Report" msgstr "" +msgid "No dataSets configuration found." +msgstr "" + +msgid "Please setup dataSet configurations here." +msgstr "" + msgid "Complete" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 0a274d4..ced065b 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2025-10-10T16:43:16.465Z\n" +"POT-Creation-Date: 2025-12-18T01:04:23.035Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -17,15 +17,78 @@ msgstr "" msgid "Submitting dataset..." msgstr "" -msgid "" +msgid "Cannot be blank: {{fieldName}}" msgstr "" -msgid "LOG OUT" +msgid "The same value is not allowed for: {{fieldName}}" +msgstr "" + +msgid "Please complete all required fields." +msgstr "" + +msgid "DataSet" +msgstr "" + +msgid "DataSet Approval" +msgstr "" + +msgid "Submit Date DataElement" +msgstr "" + +msgid "Approval Date DataElement" +msgstr "" + +msgid "Data Source" +msgstr "" + +msgid "Old Periods Data Source" +msgstr "" + +msgid "Submit also approves the dataSet" +msgstr "" + +msgid "Revoke also marks dataSet as incomplete" +msgstr "" + +msgid "Edit \"{{action}}\" Permissions" +msgstr "" + +msgid "Save" +msgstr "" + +msgid "Cancel" +msgstr "" + +msgid "DataSet Code" +msgstr "" + +msgid "DataSet Approval Code" +msgstr "" + +msgid "DataElement Submission Date" +msgstr "" + +msgid "DataElement Approval Date" +msgstr "" + +msgid "Edit" +msgstr "" + +msgid "Delete" +msgstr "" + +msgid "" msgstr "" msgid "Close" msgstr "" +msgid "Click on the ID to select the row" +msgstr "" + +msgid "LOG OUT" +msgstr "" + msgid "Loading..." msgstr "" @@ -50,9 +113,6 @@ msgstr "" msgid "Confirm" msgstr "" -msgid "Cancel" -msgstr "" - msgid "" "This action will delete the stored data and process all the Indicators and " "Program Indicators from this DHIS2 instance." @@ -64,6 +124,21 @@ msgstr "" msgid "Are you sure?" msgstr "" +msgid "Delete DataSet Configuration" +msgstr "" + +msgid "Are you sure you want to delete this DataSet Configuration?" +msgstr "" + +msgid "Edit Configuration" +msgstr "" + +msgid "Add Configuration" +msgstr "" + +msgid "DataSet Configurations" +msgstr "" + msgid "Metadata Admin Report" msgstr "" @@ -396,9 +471,6 @@ msgstr "" msgid "GLASS Admin Maintenance Report" msgstr "" -msgid "Delete" -msgstr "" - msgid "File" msgstr "" @@ -617,6 +689,12 @@ msgstr "" msgid "Data Approval Report" msgstr "" +msgid "No dataSets configuration found." +msgstr "" + +msgid "Please setup dataSet configurations here." +msgstr "" + msgid "Complete" msgstr "" diff --git a/package.json b/package.json index 6326a03..6403c5b 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { - "name": "d2-reports", - "description": "DHIS2 Reports", - "version": "1.0.0", + "name": "data-approval-dev", + "description": "Approval Report", + "version": "1.0.0-beta.1", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", "repository": { "type": "git", - "url": "git+https://github.com/eyeseetea/d2-reports.git" + "url": "git+https://github.com/eyeseetea/data-approval-dev.git" }, "dependencies": { "@dhis2/app-runtime": "3.2.5", @@ -36,16 +36,19 @@ "font-awesome": "4.7.0", "jszip": "^3.10.1", "lodash": "4.17.21", + "md5.js": "1.3.5", "node-html-parser": "5.1.0", "postcss-rtl": "1.7.3", "purify-ts": "0.16.3", "purify-ts-extra-codec": "0.6.0", "react": "17.0.2", "react-dom": "17.0.2", - "react-router-dom": "6.0.2", + "react-router-dom": "6.30.2", "react-scripts": "4.0.3", + "real-cancellable-promise": "1.2.3", "styled-components": "5.3.3", - "styled-jsx": "4.0.1" + "styled-jsx": "4.0.1", + "typed-immutable-map": "0.2.0" }, "scripts": { "prestart": "yarn localize", @@ -54,96 +57,20 @@ "prebuild": "yarn localize && yarn test", "build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp && react-scripts build && yarn run manifest && cp -r i18n icon.png build", "build-default": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest_mal.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "authorities-monitoring-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_authorities_monitoring && react-scripts build && yarn run authorities-monitoring-manifest && cp -r i18n icon.png build", - "authorities-monitoring-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn authorities-monitoring-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "two-factor-monitoring-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_twofactor_monitoring && react-scripts build && yarn run two-factor-monitoring-manifest && cp -r i18n icon.png build", - "two-factor-monitoring-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn two-factor-monitoring-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "csy-audit-emergency-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_csy_audit_emergency && react-scripts build && yarn run csy-audit-emergency-manifest && cp -r i18n icon.png build", - "csy-audit-emergency-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn csy-audit-emergency-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "csy-audit-trauma-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_csy_audit_trauma && react-scripts build && yarn run csy-audit-trauma-manifest && cp -r i18n icon.png build", - "csy-audit-trauma-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn csy-audit-trauma-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "csy-summary-mortality-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_csy_summary_mortality && react-scripts build && yarn run csy-summary-mortality-manifest && cp -r i18n icon.png build", - "csy-summary-mortality-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn csy-summary-mortality-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "csy-summary-patient-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_csy_summary_patient && react-scripts build && yarn run csy-summary-patient-manifest && cp -r i18n icon.png build", - "csy-summary-patient-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn csy-summary-patient-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "data-quality-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_data_quality && react-scripts build && yarn run data-quality-manifest && cp -r i18n icon.png build", - "data-quality-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn data-quality-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "mal-subscription-status-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_mal_subscription && react-scripts build && yarn run mal-subscription-manifest && cp -r i18n icon.png build", - "mal-subscription-status-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn mal-subscription-status-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "nhwa-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa && react-scripts build && yarn run nhwa-manifest && cp -r i18n mal-icon.png build", - "nhwa-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "mal-build-folder": "rm -rf build/ && yarn run mal-manifest && react-scripts build && yarn run mal-build-manifest && cp -r i18n mal-icon.png build", - "mal-build": "yarn mal-build-folder && rm -f $npm_package_name.zip && cd build && mv manifest_mal.json manifest.json && mv mal-favicon.ico favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "glass-admin-build-folder": "rm -rf build/ && yarn run glass-admin-manifest && react-scripts build && yarn run glass-admin-build-manifest && cp -r i18n glass-icon.png build", - "glass-admin-build": "yarn glass-admin-build-folder && rm -f d2-reports-glass-admin.zip && cd build && mv manifest_glass.json manifest.json && mv glass-favicon.ico favicon.ico && zip --quiet -r ../d2-reports-glass-admin.zip *", - "glass-build-folder": "rm -rf build/ && yarn run glass-manifest && react-scripts build && yarn run glass-build-manifest && cp -r i18n glass-icon.png build", - "glass-submission-build": "yarn glass-build-folder && rm -f d2-reports-glass.zip && cd build && mv manifest_glass.json manifest.json && mv glass-favicon.ico favicon.ico && zip --quiet -r ../d2-reports-glass.zip *", - "build-report": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn build && gulp build-report", - "build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' npx ts-node -P tsconfig-cli.json src/scripts/build.ts", - "build-nhwa-metadata": "npx ts-node -P tsconfig-cli.json src/scripts/build-nhwa-metadata.ts", - "post-metadata": "npx ts-node -P tsconfig-cli.json src/scripts/post-metadata.ts", - "build-mal-metadata": "npx ts-node -P tsconfig-cli.json src/scripts/build-mal-metadata.ts", + "nhwa-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp && react-scripts build && yarn run manifest && cp -r i18n mal-icon.png build", + "build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", "test": "jest --passWithNoTests", "lint": "eslint src --ext .js,.jsx,.ts,.tsx", "eject": "react-scripts eject", "prettify": "prettier \"./**/*.{js,jsx,json,css,ts,tsx}\" --write", "extract-pot": "yarn d2-i18n-extract -p src/ -o i18n/", - "localize": "yarn update-po && d2-i18n-generate -n d2-reports -p ./i18n/ -o ./src/locales/", + "localize": "yarn update-po && d2-i18n-generate -n data-approval-dev -p ./i18n/ -o ./src/locales/", "update-po": "yarn extract-pot && for pofile in i18n/*.po; do msgmerge --backup=off -U $pofile i18n/en.pot; done", "prepare": "husky install", "manifest": "d2-manifest package.json build/manifest.webapp", - "authorities-monitoring-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_authorities_monitoring", - "two-factor-monitoring-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_twofactor_monitoring", - "csy-audit-emergency-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_csy_audit_emergency", - "csy-audit-trauma-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_csy_audit_trauma", - "csy-summary-mortality-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_csy_summary_mortality", - "csy-summary-patient-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_csy_summary_patient", - "data-quality-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_data_quality", - "mal-subscription-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_mal_subscription", - "nhwa-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa", - "mal-manifest": "d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_mal --manifest.icons.48='mal-icon.png' --manifest.name='NHWA Approval Report' --manifest.description='NHWA Approval Report'", - "mal-build-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_mal --manifest.icons.48 mal-icon.png --manifest.name='NHWA Approval Report' --manifest.description='NHWA Approval Report'", - "glass-admin-manifest": "d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_glass_admin --manifest.icons.48='glass-icon.png' --manifest.name='DHIS2 GLASS Admin Maintenance Report' --manifest.description='DHIS2 GLASS Admin Maintenance Report'", - "glass-admin-build-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_glass_admin --manifest.icons.48 glass-icon.png --manifest.name='DHIS2 GLASS Admin Maintenance Report' --manifest.description='DHIS2 GLASS Admin Maintenance Report'", - "glass-manifest": "d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_glass --manifest.icons.48='glass-icon.png' --manifest.name='DHIS2 GLASS Submission Report' --manifest.description='DHIS2 GLASS Submission Report'", - "glass-build-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_glass --manifest.icons.48 glass-icon.png --manifest.name='DHIS2 GLASS Submission Report' --manifest.description='DHIS2 GLASS Submission Report'", - "nhwa-auto-complete-compute-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-auto-complete-compute", - "nhwa-auto-complete-compute-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-auto-complete-compute && react-scripts build && yarn run nhwa-auto-complete-compute-manifest && cp -r i18n icon.png build", - "nhwa-auto-complete-compute-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-auto-complete-compute-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "nhwa-auto-complete-compute-subnational-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-auto-complete-compute-subnational", - "nhwa-auto-complete-compute-subnational-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-auto-complete-compute-subnational && react-scripts build && yarn run nhwa-auto-complete-compute-subnational-manifest && cp -r i18n icon.png build", - "nhwa-auto-complete-compute-subnational-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-auto-complete-compute-subnational-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "nhwa-fix-totals-activity-level-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-fix-totals-activity-level", - "nhwa-fix-totals-activity-level-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-fix-totals-activity-level && react-scripts build && yarn run nhwa-fix-totals-activity-level-manifest && cp -r i18n icon.png build", - "nhwa-fix-totals-activity-level-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-fix-totals-activity-level-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "nhwa-subnational-correct-orgunit-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_subnational-correct-orgunit", - "nhwa-subnational-correct-orgunit-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-subnational-correct-orgunit && react-scripts build && yarn run nhwa-subnational-correct-orgunit-manifest && cp -r i18n icon.png build", - "nhwa-subnational-correct-orgunit-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-subnational-correct-orgunit-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "nhwa-approval-status-build-folder": "rm -rf build/ && d2-manifest package.json manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-approval-status && react-scripts build && yarn run nhwa-approval-status-manifest && cp -r i18n icon.png build", - "nhwa-approval-status-build": "REACT_APP_DHIS2_BASE_URL='' REACT_APP_DHIS2_AUTH='' yarn nhwa-approval-status-build-folder && rm -f $npm_package_name.zip && cd build && rm -f manifest.json mal-favicon.ico && zip --quiet -r ../$npm_package_name.zip *", - "nhwa-approval-status-manifest": "d2-manifest package.json build/manifest.webapp --manifest.version=$npm_package_reportVersions_nhwa-approval-status", "approve-mal-datavalues": "npx ts-node -P tsconfig-cli.json src/scripts/approve-mal-datavalues.ts", - "check-data-differences": "npx ts-node -P tsconfig-cli.json src/scripts/check-data-differences.ts" - }, - "reportVersions": { - "two-factor-monitoring": "1.0.0", - "authorities-monitoring": "1.0.0", - "csy-audit-emergency": "1.0.0", - "csy-audit-trauma": "1.0.0", - "csy-summary-mortality": "1.0.0", - "csy-summary-patient": "1.0.0", - "data-quality": "1.0.0", - "glass-admin": "1.1.0", - "glass-submission": "1.0.7", - "glass": "1.0.7", - "mal": "0.1.3", - "mal-subscription": "1.0.0", - "nhwa-auto-complete-compute": "1.0.0", - "nhwa-auto-complete-compute-subnational": "1.0.0", - "nhwa-fix-totals-activity-level": "1.0.0", - "nhwa-subnational-correct-orgunit": "1.0.0", - "nhwa-approval-status": "1.0.0", - "nhwa": "1.0.0" + "check-data-differences": "npx ts-node -P tsconfig-cli.json src/scripts/check-data-differences.ts", + "generate-sqlviews": "npx tsx src/scripts/generate-sqlviews.ts" }, "husky": { "hooks": { @@ -200,8 +127,8 @@ "wait-on": "6.0.0" }, "manifest.webapp": { - "name": "NHWA Approval Report", - "description": "NHWA Approval Report", + "name": "Approval Report", + "description": "Approval Report", "icons": { "48": "mal-icon.png" }, diff --git a/src/compositionRoot.ts b/src/compositionRoot.ts index 548c170..ea32661 100644 --- a/src/compositionRoot.ts +++ b/src/compositionRoot.ts @@ -1,26 +1,13 @@ import { Dhis2ConfigRepository } from "./data/common/Dhis2ConfigRepository"; import { Dhis2OrgUnitsRepository } from "./data/common/Dhis2OrgUnitsRepository"; -import { NHWADataApprovalDefaultRepository } from "./data/reports/nhwa-approval-status/NHWADataApprovalDefaultRepository"; -import { NHWADataCommentsDefaultRepository } from "./data/reports/nhwa-comments/NHWADataCommentsDefaultRepository"; -import { WIDPAdminDefaultRepository } from "./data/reports/admin/WIDPAdminDefaultRepository"; -import { GetWIDPAdminDefaultUseCase } from "./domain/reports/admin/usecases/GetWIDPAdminDefaultUseCase"; -import { SaveWIDPAdminDefaultCsvUseCase } from "./domain/reports/admin/usecases/SaveWIDPAdminDefaultCsvUseCase"; import { GetConfig } from "./domain/common/usecases/GetConfig"; import { GetOrgUnitsUseCase } from "./domain/common/usecases/GetOrgUnitsUseCase"; -import { UpdateStatusUseCase } from "./domain/reports/nhwa-approval-status/usecases/UpdateStatusUseCase"; -import { GetApprovalColumnsUseCase } from "./domain/reports/nhwa-approval-status/usecases/GetApprovalColumnsUseCase"; -import { GetDataSetsUseCase } from "./domain/reports/nhwa-approval-status/usecases/GetDataSetsUseCase"; -import { SaveApprovalColumnsUseCase } from "./domain/reports/nhwa-approval-status/usecases/SaveApprovalColumnsUseCase"; -import { SaveDataSetsUseCase } from "./domain/reports/nhwa-approval-status/usecases/SaveDataSetsCsvUseCase"; -import { GetDataValuesUseCase } from "./domain/reports/nhwa-comments/usecases/GetDataValuesUseCase"; -import { SaveDataValuesUseCase } from "./domain/reports/nhwa-comments/usecases/SaveDataValuesCsvUseCase"; import { UpdateMalApprovalStatusUseCase } from "./domain/reports/mal-data-approval/usecases/UpdateMalApprovalStatusUseCase"; import { GetMalDataSetsUseCase } from "./domain/reports/mal-data-approval/usecases/GetMalDataSetsUseCase"; import { SaveMalDataApprovalColumnsUseCase } from "./domain/reports/mal-data-approval/usecases/SaveMalDataApprovalColumnsUseCase"; import { SaveMalDataSetsUseCase } from "./domain/reports/mal-data-approval/usecases/SaveMalDataSetsUseCase"; import { D2Api } from "./types/d2-api"; import { GetMalDataDiffUseCase } from "./domain/reports/mal-data-approval/usecases/GetMalDataDiffUseCase"; -import { getReportType } from "./webapp/utils/reportType"; import { GetMalDataApprovalColumnsUseCase } from "./domain/reports/mal-data-approval/usecases/GetMalDataApprovalColumnsUseCase"; import { MalDataApprovalDefaultRepository } from "./data/reports/mal-data-approval/MalDataApprovalDefaultRepository"; import { MalDataSubscriptionDefaultRepository } from "./data/reports/mal-data-subscription/MalDataSubscriptionDefaultRepository"; @@ -35,144 +22,79 @@ import { SaveSubscriptionUseCase } from "./domain/reports/mal-data-subscription/ import { GetSubscriptionUseCase } from "./domain/reports/mal-data-subscription/usecases/GetSubscriptionUseCase"; import { GetMonitoringUseCase as GetSubscriptionMonitoringUseCase } from "./domain/reports/mal-data-subscription/usecases/GetMonitoringUseCase"; import { SaveMonitoringUseCase as SaveSubscriptionMonitoringUseCase } from "./domain/reports/mal-data-subscription/usecases/SaveMonitoringUseCase"; -import { AuditItemD2Repository as CSYAuditEmergencyD2Repository } from "./data/reports/csy-audit-emergency/AuditItemD2Repository"; -import { GetAuditEmergencyUseCase } from "./domain/reports/csy-audit-emergency/usecases/GetAuditEmergencyUseCase"; -import { SaveAuditEmergencyUseCase } from "./domain/reports/csy-audit-emergency/usecases/SaveAuditEmergencyUseCase"; -import { GetAuditTraumaUseCase } from "./domain/reports/csy-audit-trauma/usecases/GetAuditTraumaUseCase"; -import { SaveAuditTraumaUseCase } from "./domain/reports/csy-audit-trauma/usecases/SaveAuditTraumaUseCase"; -import { GLASSDataSubmissionDefaultRepository } from "./data/reports/glass-data-submission/GLASSDataSubmissionDefaultRepository"; -import { GetGLASSDataSubmissionUseCase } from "./domain/reports/glass-data-submission/usecases/GetGLASSDataSubmissionUseCase"; -import { GetGLASSDataSubmissionColumnsUseCase } from "./domain/reports/glass-data-submission/usecases/GetGLASSDataSubmissionColumnsUseCase"; -import { SaveGLASSDataSubmissionColumnsUseCase } from "./domain/reports/glass-data-submission/usecases/SaveGLASSDataSubmissionColumnsUseCase"; -import { UpdateGLASSSubmissionUseCase } from "./domain/reports/glass-data-submission/usecases/UpdateGLASSSubmissionUseCase"; -import { DHIS2MessageCountUseCase } from "./domain/reports/glass-data-submission/usecases/DHIS2MessageCountUseCase"; -import { SummaryItemD2Repository as CSYSummaryPatientD2Repository } from "./data/reports/csy-summary-patient/SummaryItemD2Repository"; -import { GetSummaryUseCase } from "./domain/reports/csy-summary-patient/usecases/GetSummaryUseCase"; -import { SaveSummaryUseCase } from "./domain/reports/csy-summary-patient/usecases/SaveSummaryUseCase"; -import { SummaryItemD2Repository as CSYSummaryMortalityD2Repository } from "./data/reports/csy-summary-mortality/SummaryItemD2Repository"; -import { GetSummaryMortalityUseCase } from "./domain/reports/csy-summary-mortality/usecases/GetSummaryUseCase"; -import { SaveSummaryMortalityUseCase } from "./domain/reports/csy-summary-mortality/usecases/SaveSummaryUseCase"; -import { AuditItemD2Repository as CSYAuditTraumaD2Repository } from "./data/reports/csy-audit-trauma/AuditItemD2Repository"; import { GetMalDashboardsSubscriptionUseCase } from "./domain/reports/mal-data-subscription/usecases/GetMalDashboardsSubscriptionUseCase"; -import { GetAutoCompleteComputeValuesUseCase } from "./domain/reports/nhwa-auto-complete-compute/usecases/GetAutoCompleteComputeValuesUseCase"; import { DataSetD2Repository } from "./data/common/DataSetD2Repository"; import { DataValuesD2Repository } from "./data/common/DataValuesD2Repository"; -import { FixAutoCompleteComputeValuesUseCase } from "./domain/reports/nhwa-auto-complete-compute/usecases/FixAutoCompleteComputeValuesUseCase"; import { GetOrgUnitsByLevelUseCase } from "./domain/common/usecases/GetOrgUnitsByLevelUseCase"; -import { AutoCompleteComputeSettingsD2Repository } from "./data/reports/nhwa-auto-complete-compute/AutoCompleteComputeSettingsD2Repository"; -import { GetTotalsByActivityLevelUseCase } from "./domain/reports/nhwa-fix-totals/GetTotalsByActivityLevelUseCase"; -import { FixTotalsValuesUseCase } from "./domain/reports/nhwa-fix-totals/usecases/FixTotalsValuesUseCase"; -import { FixTotalsSettingsD2Repository } from "./data/reports/nhwa-fix-totals/FixTotalsSettingsD2Repository"; -import { SubnationalCorrectD2Repository } from "./data/reports/nhwa-subnational-correct-orgunit/SubnationalCorrectD2Repository"; -import { GetSubnationalCorrectUseCase } from "./domain/reports/nhwa-subnational-correct-orgunit/usecases/GetSubnationalCorrectUseCase"; -import { DismissSubnationalCorrectValuesUseCase } from "./domain/reports/nhwa-subnational-correct-orgunit/usecases/DismissSubnationalCorrectValuesUseCase"; -import { SubnationalCorrectD2SettingsRepository } from "./data/reports/nhwa-subnational-correct-orgunit/SubnationalCorrectD2SettingsRepository"; -import { GetEARDataSubmissionUseCase } from "./domain/reports/glass-data-submission/usecases/GetEARDataSubmissionUseCase"; -import { DataQualityDefaultRepository } from "./data/reports/data-quality/DataQualityDefaultRepository"; -import { GetIndicatorsUseCase } from "./domain/reports/data-quality/usecases/GetIndicatorsUseCase"; -import { GetProgramIndicatorsUseCase } from "./domain/reports/data-quality/usecases/GetProgramIndicatorsUseCase"; -import { SaveDataQualityColumnsUseCase } from "./domain/reports/data-quality/usecases/SaveDataQualityColumnsUseCase"; -import { GetDataQualityColumnsUseCase } from "./domain/reports/data-quality/usecases/GetDataQualityColumnsUseCase"; -import { SaveDataQualityUseCase } from "./domain/reports/data-quality/usecases/SaveDataQualityUseCase"; -import { LoadDataQualityValidation } from "./domain/reports/data-quality/usecases/loadDataQualityValidation"; -import { ResetDataQualityValidation } from "./domain/reports/data-quality/usecases/ResetDataQualityValidation"; import { GetMonitoringDetailsUseCase } from "./domain/reports/mal-data-subscription/usecases/GetMonitoringDetailsUseCase"; -import { AuthoritiesMonitoringDefaultRepository } from "./data/reports/authorities-monitoring/AuthoritiesMonitoringDefaultRepository"; -import { GetAuthoritiesMonitoringUseCase } from "./domain/reports/authorities-monitoring/usecases/GetAuthoritiesMonitoringUseCase"; -import { GetAuthoritiesMonitoringColumnsUseCase } from "./domain/reports/authorities-monitoring/usecases/GetAuthoritiesMonitoringColumnsUseCase"; -import { SaveAuthoritiesMonitoringColumnsUseCase } from "./domain/reports/authorities-monitoring/usecases/SaveAuthoritiesMonitoringColumnsUseCase"; -import { GetGLASSDataMaintenanceUseCase } from "./domain/reports/glass-admin/usecases/GetGLASSDataMaintenanceUseCase"; -import { GLASSDataMaintenanceDefaultRepository } from "./data/reports/glass-admin/GLASSDataMaintenanceDefaultRepository"; -import { GetGLASSDataMaintenanceColumnsUseCase } from "./domain/reports/glass-admin/usecases/GetGLASSDataMaintenanceColumnsUseCase"; -import { SaveGLASSDataMaintenanceColumnsUseCase } from "./domain/reports/glass-admin/usecases/SaveGLASSDataMaintenanceColumnsUseCase"; -import { GetGLASSModulesUseCase } from "./domain/reports/glass-admin/usecases/GetGLASSModulesUseCase"; -import { UpdateGLASSDataMaintenanceUseCase } from "./domain/reports/glass-admin/usecases/UpdateGLASSDataMaintenanceUseCase"; -import { GetATCsUseCase } from "./domain/reports/glass-admin/usecases/GetATCsUseCase"; -import { UploadATCFileUseCase } from "./domain/reports/glass-admin/usecases/UploadATCFileUseCase"; -import { SaveAMCRecalculationLogic } from "./domain/reports/glass-admin/usecases/SaveAMCRecalculationLogic"; -import { GetATCLoggerProgramUseCase } from "./domain/reports/glass-admin/usecases/GetATCLoggerProgramUseCase"; -import { GetATCRecalculationLogicUseCase } from "./domain/reports/glass-admin/usecases/GetATCRecalculationLogicUseCase"; -import { CancelRecalculationUseCase } from "./domain/reports/glass-admin/usecases/CancelRecalculationUseCase"; -import { GetGLASSDataSubmissionModulesUseCase } from "./domain/reports/glass-data-submission/usecases/GetGLASSDataSubmissionModulesUseCase"; -import { SaveAuthoritiesMonitoringUseCase } from "./domain/reports/authorities-monitoring/usecases/SaveAuthoritiesMonitoringUseCase"; -import { GetAutoCompleteComputeSettingsUseCase } from "./domain/reports/nhwa-auto-complete-compute/usecases/GetAutoCompleteComputeSettingsUseCase"; -import { GetMonitoringTwoFactorUseCase } from "./domain/reports/twofactor-monitoring/usecases/GetMonitoringTwoFactorUseCase"; -import { SaveMonitoringTwoFactorColumnsUseCase } from "./domain/reports/twofactor-monitoring/usecases/SaveMonitoringTwoFactorColumnsUseCase"; -import { GetMonitoringTwoFactorColumnsUseCase } from "./domain/reports/twofactor-monitoring/usecases/GetMonitoringTwoFactorColumnsUseCase"; -import { SaveMonitoringTwoFactorUseCase } from "./domain/reports/twofactor-monitoring/usecases/SaveMonitoringTwoFactorUseCase"; -import { MonitoringTwoFactorD2Repository } from "./data/reports/twofactor-monitoring/MonitoringTwoFactorD2Repository"; -import { GetOrgUnitsWithChildrenUseCase } from "./domain/reports/glass-data-submission/usecases/GetOrgUnitsWithChildrenUseCase"; import { GetAllOrgUnitsByLevelUseCase } from "./domain/common/usecases/GetAllOrgUnitsByLevelUseCase"; import { GetMalDataApprovalOUsWithChildrenUseCase } from "./domain/reports/mal-data-approval/usecases/GetMalDataApprovalOUsWithChildrenUseCase"; import { OrgUnitWithChildrenD2Repository } from "./data/reports/mal-data-approval/OrgUnitWithChildrenD2Repository"; import { UpdateMonitoringUseCase } from "./domain/reports/mal-data-approval/usecases/UpdateMonitoringUseCase"; import { UserGroupD2Repository } from "./data/reports/mal-data-approval/UserGroupD2Repository"; import { MonitoringValueDataStoreRepository } from "./data/reports/mal-data-approval/MonitoringValueDataStoreRepository"; -import { AppSettingsD2Repository } from "./data/AppSettingsD2Repository"; -import { GetAppSettingsUseCase } from "./domain/usecases/GetAppSettingsUseCase"; import { DataSetStatusD2Repository } from "./data/DataSetStatusD2Repository"; -import { GetDataSetStatusUseCase } from "./domain/usecases/GetDataSetStatusUseCase"; +import { GetDataSetConfigurationsUseCase } from "./domain/usecases/GetDataSetConfigurationsUseCase"; +import { UserD2Repository } from "./data/UserD2Repository"; +import { DataSetConfigurationD2Repository } from "./data/DataSetConfigurationD2Repository"; +import { GetUsersByUsernameUseCase } from "./domain/usecases/GetUsersByUsernameUseCase"; +import { GetUserGroupsByCodeUseCase } from "./domain/usecases/GetUserGroupsByCodeUseCase"; +import { SearchUsersAndUserGroupsUseCase } from "./domain/usecases/SearchUsersAndUserGroupsUseCase"; +import { UserSharingD2Repository } from "./data/UserSharingD2Repository"; +import { GetDataSetConfigurationByCodeUseCase } from "./domain/usecases/GetDataSetConfigurationByCodeUseCase"; +import { MetadataEntityD2Repository } from "./data/MetadataEntityD2Repository"; +import { GetMetadataEntitiesUseCase } from "./domain/usecases/GetMetadataEntitiesUseCase"; +import { SaveDataSetConfigurationUseCase } from "./domain/usecases/SaveDataSetConfigurationUseCase"; +import { GetApprovalConfigurationsUseCase } from "./domain/usecases/GetApprovalConfigurationsUseCase"; +import { RemoveDataSetConfigurationUseCase } from "./domain/usecases/RemoveDataSetConfigurationUseCase"; export function getCompositionRoot(api: D2Api) { - const configRepository = new Dhis2ConfigRepository(api, getReportType()); - const csyAuditEmergencyRepository = new CSYAuditEmergencyD2Repository(api); - const csyAuditTraumaRepository = new CSYAuditTraumaD2Repository(api); - const dataCommentsRepository = new NHWADataCommentsDefaultRepository(api); - const dataApprovalRepository = new NHWADataApprovalDefaultRepository(api); + const configRepository = new Dhis2ConfigRepository(api); const dataDuplicationRepository = new MalDataApprovalDefaultRepository(api); const dataSubscriptionRepository = new MalDataSubscriptionDefaultRepository(api); - const dataQualityRepository = new DataQualityDefaultRepository(api); - const widpAdminDefaultRepository = new WIDPAdminDefaultRepository(api); - const glassAdminRepository = new GLASSDataMaintenanceDefaultRepository(api); - const glassDataRepository = new GLASSDataSubmissionDefaultRepository(api); - const csySummaryPatientRepository = new CSYSummaryPatientD2Repository(api); - const csySummaryMortalityRepository = new CSYSummaryMortalityD2Repository(api); const orgUnitsRepository = new Dhis2OrgUnitsRepository(api); const dataSetRepository = new DataSetD2Repository(api); const dataValuesRepository = new DataValuesD2Repository(api); - const autoCompleteComputeSettingsRepository = new AutoCompleteComputeSettingsD2Repository(api); - const fixTotalSettingsRepository = new FixTotalsSettingsD2Repository(api); - const subnationalCorrectRepository = new SubnationalCorrectD2Repository(api); - const subnationalCorrectSettingsRepository = new SubnationalCorrectD2SettingsRepository(api); - const authoritiesMonitoringRepository = new AuthoritiesMonitoringDefaultRepository(api); - const monitoringTwoFactorD2Repository = new MonitoringTwoFactorD2Repository(api); const orgUnitsWithChildrenRepository = new OrgUnitWithChildrenD2Repository(api); const userGroupRepository = new UserGroupD2Repository(api); const monitoringValueRepository = new MonitoringValueDataStoreRepository(api); - const appSettingsRepository = new AppSettingsD2Repository(); const dataSetStatusRepository = new DataSetStatusD2Repository(api); + const userRepository = new UserD2Repository(api); + const dataSetConfigurationRepository = new DataSetConfigurationD2Repository(api); + const userSharingRepository = new UserSharingD2Repository(api); + const metadataEntityRepository = new MetadataEntityD2Repository(api); return { - dataSetStatus: { - get: new GetDataSetStatusUseCase(dataSetStatusRepository), + metadata: { + getBy: new GetMetadataEntitiesUseCase({ metadataEntityRepository }), }, - appSettings: { - get: new GetAppSettingsUseCase(appSettingsRepository), + sharing: { + search: new SearchUsersAndUserGroupsUseCase({ userSharingRepository }), }, - admin: getExecute({ - get: new GetWIDPAdminDefaultUseCase(widpAdminDefaultRepository), - save: new SaveWIDPAdminDefaultCsvUseCase(widpAdminDefaultRepository), - }), - dataComments: getExecute({ - get: new GetDataValuesUseCase(dataCommentsRepository), - save: new SaveDataValuesUseCase(dataCommentsRepository), - }), - dataApproval: getExecute({ - get: new GetDataSetsUseCase(dataApprovalRepository), - save: new SaveDataSetsUseCase(dataApprovalRepository), - getColumns: new GetApprovalColumnsUseCase(dataApprovalRepository), - saveColumns: new SaveApprovalColumnsUseCase(dataApprovalRepository), - updateStatus: new UpdateStatusUseCase(dataApprovalRepository), - }), - malDataApproval: getExecute({ - get: new GetMalDataSetsUseCase( - dataDuplicationRepository, - dataValuesRepository, + userGroups: { + getByCodes: new GetUserGroupsByCodeUseCase({ userGroupRepository }), + }, + users: { + getByUsernames: new GetUsersByUsernameUseCase({ userRepository }), + }, + dataSetConfig: { + getAll: new GetDataSetConfigurationsUseCase({ + dataSetConfigurationRepository, + userRepository, dataSetRepository, - monitoringValueRepository, - appSettingsRepository - ), - getDiff: new GetMalDataDiffUseCase(dataValuesRepository, dataSetRepository, appSettingsRepository), + }), + getByCode: new GetDataSetConfigurationByCodeUseCase({ dataSetConfigurationRepository, userRepository }), + save: new SaveDataSetConfigurationUseCase({ dataSetConfigurationRepository, userRepository }), + remove: new RemoveDataSetConfigurationUseCase({ dataSetConfigurationRepository, userRepository }), + getDataSets: new GetApprovalConfigurationsUseCase({ + dataSetConfigurationRepository, + userRepository, + dataSetRepository, + }), + }, + malDataApproval: getExecute({ + get: new GetMalDataSetsUseCase(dataDuplicationRepository, dataValuesRepository, dataSetRepository), + getDiff: new GetMalDataDiffUseCase(dataValuesRepository, dataSetRepository), save: new SaveMalDataSetsUseCase(dataDuplicationRepository), getColumns: new GetMalDataApprovalColumnsUseCase(dataDuplicationRepository), saveColumns: new SaveMalDataApprovalColumnsUseCase(dataDuplicationRepository), @@ -181,8 +103,7 @@ export function getCompositionRoot(api: D2Api) { updateStatus: new UpdateMalApprovalStatusUseCase( dataDuplicationRepository, dataValuesRepository, - dataSetRepository, - appSettingsRepository + dataSetRepository ), duplicateValue: new DuplicateDataValuesUseCase(dataDuplicationRepository, dataSetStatusRepository), getSortOrder: new GetSortOrderUseCase(dataDuplicationRepository), @@ -200,54 +121,6 @@ export function getCompositionRoot(api: D2Api) { getMonitoring: new GetSubscriptionMonitoringUseCase(dataSubscriptionRepository), saveMonitoring: new SaveSubscriptionMonitoringUseCase(dataSubscriptionRepository), }), - auditEmergency: getExecute({ - get: new GetAuditEmergencyUseCase(csyAuditEmergencyRepository), - save: new SaveAuditEmergencyUseCase(csyAuditEmergencyRepository), - }), - auditTrauma: getExecute({ - get: new GetAuditTraumaUseCase(csyAuditTraumaRepository), - save: new SaveAuditTraumaUseCase(csyAuditTraumaRepository), - }), - glassAdmin: getExecute({ - get: new GetGLASSDataMaintenanceUseCase(glassAdminRepository), - getATCs: new GetATCsUseCase(glassAdminRepository), - getModules: new GetGLASSModulesUseCase(glassAdminRepository), - getATCRecalculationLogic: new GetATCRecalculationLogicUseCase(glassAdminRepository), - cancelRecalculation: new CancelRecalculationUseCase(glassAdminRepository), - getATCLoggerProgram: new GetATCLoggerProgramUseCase(glassAdminRepository), - updateStatus: new UpdateGLASSDataMaintenanceUseCase(glassAdminRepository), - saveRecalculationLogic: new SaveAMCRecalculationLogic(glassAdminRepository), - uploadFile: new UploadATCFileUseCase(glassAdminRepository), - getColumns: new GetGLASSDataMaintenanceColumnsUseCase(glassAdminRepository), - saveColumns: new SaveGLASSDataMaintenanceColumnsUseCase(glassAdminRepository), - }), - glassDataSubmission: getExecute({ - get: new GetGLASSDataSubmissionUseCase(glassDataRepository), - getModules: new GetGLASSDataSubmissionModulesUseCase(glassDataRepository), - getEAR: new GetEARDataSubmissionUseCase(glassDataRepository), - getColumns: new GetGLASSDataSubmissionColumnsUseCase(glassDataRepository), - saveColumns: new SaveGLASSDataSubmissionColumnsUseCase(glassDataRepository), - dhis2MessageCount: new DHIS2MessageCountUseCase(glassDataRepository), - updateStatus: new UpdateGLASSSubmissionUseCase(glassDataRepository), - getOrgUnitsWithChildren: new GetOrgUnitsWithChildrenUseCase(glassDataRepository), - }), - summary: getExecute({ - get: new GetSummaryUseCase(csySummaryPatientRepository), - save: new SaveSummaryUseCase(csySummaryPatientRepository), - }), - summaryMortality: getExecute({ - get: new GetSummaryMortalityUseCase(csySummaryMortalityRepository), - save: new SaveSummaryMortalityUseCase(csySummaryMortalityRepository), - }), - dataQuality: getExecute({ - getIndicators: new GetIndicatorsUseCase(dataQualityRepository), - getProgramIndicators: new GetProgramIndicatorsUseCase(dataQualityRepository), - saveDataQuality: new SaveDataQualityUseCase(dataQualityRepository), - loadValidation: new LoadDataQualityValidation(dataQualityRepository), - resetValidation: new ResetDataQualityValidation(dataQualityRepository), - getColumns: new GetDataQualityColumnsUseCase(dataQualityRepository), - saveColumns: new SaveDataQualityColumnsUseCase(dataQualityRepository), - }), orgUnits: getExecute({ getAllByLevel: new GetAllOrgUnitsByLevelUseCase(orgUnitsRepository), get: new GetOrgUnitsUseCase(orgUnitsRepository), @@ -256,39 +129,6 @@ export function getCompositionRoot(api: D2Api) { config: getExecute({ get: new GetConfig(configRepository), }), - nhwa: { - getAutoCompleteComputeSettings: new GetAutoCompleteComputeSettingsUseCase( - autoCompleteComputeSettingsRepository - ), - getAutoCompleteComputeValues: new GetAutoCompleteComputeValuesUseCase( - dataSetRepository, - dataValuesRepository - ), - fixAutoCompleteComputeValues: new FixAutoCompleteComputeValuesUseCase(dataValuesRepository), - getTotalsByActivityLevel: new GetTotalsByActivityLevelUseCase( - dataSetRepository, - dataValuesRepository, - fixTotalSettingsRepository - ), - fixTotalValues: new FixTotalsValuesUseCase(dataValuesRepository), - getSubnationalCorrectValues: new GetSubnationalCorrectUseCase(subnationalCorrectRepository), - dismissSubnationalCorrectValues: new DismissSubnationalCorrectValuesUseCase( - dataValuesRepository, - subnationalCorrectSettingsRepository - ), - }, - authMonitoring: getExecute({ - get: new GetAuthoritiesMonitoringUseCase(authoritiesMonitoringRepository), - save: new SaveAuthoritiesMonitoringUseCase(authoritiesMonitoringRepository), - getColumns: new GetAuthoritiesMonitoringColumnsUseCase(authoritiesMonitoringRepository), - saveColumns: new SaveAuthoritiesMonitoringColumnsUseCase(authoritiesMonitoringRepository), - }), - twoFactorUserMonitoring: getExecute({ - get: new GetMonitoringTwoFactorUseCase(monitoringTwoFactorD2Repository), - save: new SaveMonitoringTwoFactorUseCase(monitoringTwoFactorD2Repository), - getColumns: new GetMonitoringTwoFactorColumnsUseCase(monitoringTwoFactorD2Repository), - saveColumns: new SaveMonitoringTwoFactorColumnsUseCase(monitoringTwoFactorD2Repository), - }), }; } diff --git a/src/data/AppSettingsD2Repository.ts b/src/data/AppSettingsD2Repository.ts deleted file mode 100644 index 25f1432..0000000 --- a/src/data/AppSettingsD2Repository.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AppSettings } from "../domain/common/entities/AppSettings"; -import { AppSettingsRepository } from "../domain/common/repositories/AppSettingsRepository"; -import { D2ApprovalReport } from "./D2ApprovalReport"; - -export class AppSettingsD2Repository implements AppSettingsRepository { - private d2ApprovalReport: D2ApprovalReport; - - constructor() { - this.d2ApprovalReport = new D2ApprovalReport(); - } - - get(): Promise { - return Promise.resolve(this.d2ApprovalReport.get()); - } -} diff --git a/src/data/ApprovalReportData.ts b/src/data/ApprovalReportData.ts deleted file mode 100644 index 02c43bf..0000000 --- a/src/data/ApprovalReportData.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AppSettings } from "../domain/common/entities/AppSettings"; -import { Code } from "../domain/common/entities/Base"; - -const nhwaDataCapture1 = "NHWA _DATA Capture Module 1"; -const nhwaDataCapture2 = "NHWA _DATA Capture Module 2-4"; -const nhwaAdmin = "NHWA administrators"; -const nhwaDataClerk = "NHWA Data Clerk"; -const nhwaDataManagers = "NHWA Data Managers"; - -export const approvalReportAccess: D2ApprovalReportAccess = { - dataSets: { - "NHWA-M1-2023": { - complete: { userGroups: [nhwaAdmin, nhwaDataClerk, nhwaDataManagers], users: [] }, - incomplete: { userGroups: [nhwaAdmin, nhwaDataClerk, nhwaDataManagers], users: [] }, - monitoring: { userGroups: [nhwaAdmin], users: [] }, - read: { userGroups: [nhwaAdmin, nhwaDataClerk, nhwaDataCapture1, nhwaDataManagers], users: [] }, - revoke: { userGroups: [nhwaAdmin, nhwaDataManagers], users: [] }, - submit: { userGroups: [nhwaAdmin, nhwaDataManagers], users: [] }, - approve: { userGroups: [nhwaAdmin], users: [] }, - }, - "NHWA-M2-2023": { - complete: { userGroups: [nhwaAdmin, nhwaDataClerk, nhwaDataManagers], users: [] }, - incomplete: { userGroups: [nhwaAdmin, nhwaDataClerk, nhwaDataManagers], users: [] }, - monitoring: { userGroups: [nhwaAdmin], users: [] }, - read: { userGroups: [nhwaAdmin, nhwaDataClerk, nhwaDataCapture2, nhwaDataManagers], users: [] }, - revoke: { userGroups: [nhwaAdmin, nhwaDataManagers], users: [] }, - submit: { userGroups: [nhwaAdmin, nhwaDataManagers], users: [] }, - approve: { userGroups: [nhwaAdmin], users: [] }, - }, - }, -}; - -export const approvalReportSettings: AppSettings = { - dataSets: { - "NHWA-M1-2023": { - dataSourceId: "hvvqvHV6jIq", - oldDataSourceId: "CadymAq0Vsi", - approvalDataSetCode: "NHWA-M1-2023-APVD", - dataElements: { - approvalDate: "NHWA_APPROVAL_DATE_MODULE1-APVD", - submissionDate: "NHWA_SUBMISSION_DATE_MODULE1-APVD", - }, - }, - "NHWA-M2-2023": { - dataSourceId: "h8TOj5HtMM8", - oldDataSourceId: "JzrrYb7fjMC", - approvalDataSetCode: "NHWA-M2-2023-APVD", - dataElements: { - approvalDate: "NHWA_APPROVAL_DATE_MODULE2-APVD", - submissionDate: "NHWA_SUBMISSION_DATE_MODULE2-APVD", - }, - }, - }, -}; - -type D2ApprovalReportAccess = Record<"dataSets", D2ApprovalAccessDataSets>; - -type D2ApprovalAccessDataSets = Record< - Code, - { - complete: AccessSettings; - incomplete: AccessSettings; - monitoring: AccessSettings; - read: AccessSettings; - revoke: AccessSettings; - submit: AccessSettings; - approve: AccessSettings; - } ->; - -type AccessSettings = { - userGroups: Code[]; - users: Code[]; -}; diff --git a/src/data/D2ApprovalReport.ts b/src/data/D2ApprovalReport.ts deleted file mode 100644 index a8d43f9..0000000 --- a/src/data/D2ApprovalReport.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { approvalReportSettings } from "./ApprovalReportData"; - -export class D2ApprovalReport { - get() { - return approvalReportSettings; - } -} diff --git a/src/data/DataSetConfigurationD2Repository.ts b/src/data/DataSetConfigurationD2Repository.ts new file mode 100644 index 0000000..13ce4a4 --- /dev/null +++ b/src/data/DataSetConfigurationD2Repository.ts @@ -0,0 +1,60 @@ +import { DataSetConfiguration } from "../domain/entities/DataSetConfiguration"; +import { Future, FutureData } from "../domain/generic/Future"; +import { DataSetConfigurationRepository } from "../domain/repositories/DataSetConfigurationRepository"; +import { D2Api } from "../types/d2-api"; +import { apiToFuture } from "./api-futures"; + +export class DataSetConfigurationD2Repository implements DataSetConfigurationRepository { + private namespace = "dataset-approval"; + private dataStoreUrl = `/dataStore/${this.namespace}`; + constructor(private api: D2Api) {} + + remove(id: string): FutureData { + const dataStore = this.api.dataStore(this.namespace); + return apiToFuture(dataStore.delete(`DS_${id}`)).toVoid(); + } + + getByCode(code: string): FutureData { + const dataStore = this.api.dataStore(this.namespace); + return apiToFuture(dataStore.get(`DS_${code}`)).flatMap(data => { + return data + ? Future.success(DataSetConfiguration.create(data)) + : Future.error(new Error(`DataSetConfiguration with code ${code} not found`)); + }); + } + + getAll(): FutureData { + return apiToFuture( + this.api.request({ + method: "get", + url: this.dataStoreUrl, + params: dataStoreFields, + }) + ).map(response => { + const onlyDataSetConfigs = response.entries.filter(entry => + entry.key.startsWith(DataSetConfiguration.CODE_PREFIX) + ); + return onlyDataSetConfigs.map(entry => { + return DataSetConfiguration.create(entry.value); + }); + }); + } + + save(configuration: DataSetConfiguration): FutureData { + const dataStore = this.api.dataStore(this.namespace); + return apiToFuture(dataStore.get(`DS_${configuration.id}`)).flatMap(existingData => { + return apiToFuture( + dataStore.save(`DS_${configuration.id}`, { ...existingData, ...configuration }) + ).toVoid(); + }); + } +} + +type D2DataStoreFields = { + pager: { page: number; pageSize: number }; + entries: D2Entries[]; +}; + +type D2Entries = { key: string; value: DataSetConfiguration }; + +const dataStoreFields = { fields: "." }; diff --git a/src/data/MetadataEntityD2Repository.ts b/src/data/MetadataEntityD2Repository.ts new file mode 100644 index 0000000..c22c5fd --- /dev/null +++ b/src/data/MetadataEntityD2Repository.ts @@ -0,0 +1,71 @@ +import _ from "../domain/generic/Collection"; +import { PaginatedObjects } from "../domain/common/entities/PaginatedObjects"; +import { MetadataEntity } from "../domain/entities/MetadataEntity"; +import { FutureData } from "../domain/generic/Future"; +import { + GetMetadataEntityOptions, + MetadataEntityRepository, + MetadataEntityType, +} from "../domain/repositories/MetadataEntityRepository"; +import { D2Api } from "../types/d2-api"; +import { apiToFuture } from "./api-futures"; + +export class MetadataEntityD2Repository implements MetadataEntityRepository { + constructor(private api: D2Api) {} + + getBy(options: GetMetadataEntityOptions): FutureData> { + const { type, page, pageSize } = options; + + const filter = this.buildFilters(options); + + return apiToFuture( + this.api.request>({ + method: "get", + url: `/${type}`, + params: { + fields: "id,displayName,code", + page, + pageSize, + filter, + }, + }) + ).map(res => { + return { + pager: { + page: res.pager.page, + pageSize: res.pager.pageSize, + total: res.pager.total, + pageCount: res.pager.pageCount, + }, + objects: res[type].map(obj => + MetadataEntity.create({ code: obj.code, id: obj.id, name: obj.displayName }) + ), + }; + }); + } + + private buildFilters(options: GetMetadataEntityOptions): string[] { + const { search, onlyWithCode } = options; + const searchFilter = search ? `identifiable:token:${search}` : undefined; + const codeFilter = onlyWithCode ? "code:ge:0" : undefined; + + return _([searchFilter, codeFilter]) + .compactMap(value => value) + .value(); + } +} + +type DynamicApiResponse = Record & { + pager: { + page: number; + pageSize: number; + total: number; + pageCount: number; + }; +}; + +type CommonObject = { + id: string; + displayName: string; + code: string; +}; diff --git a/src/data/UserD2Repository.ts b/src/data/UserD2Repository.ts new file mode 100644 index 0000000..4cc394a --- /dev/null +++ b/src/data/UserD2Repository.ts @@ -0,0 +1,74 @@ +import { D2Api, MetadataPick } from "../types/d2-api"; +import { UserRepository } from "../domain/repositories/UserRepository"; +import { Future, FutureData } from "../domain/generic/Future"; +import { apiToFuture } from "./api-futures"; +import _ from "../domain/generic/Collection"; +import { User } from "../domain/entities/User"; + +export class UserD2Repository implements UserRepository { + constructor(private api: D2Api) {} + + getByUsernames(usernames: string[]): FutureData { + if (usernames.length === 0) return Future.success([]); + const $requests = _(usernames) + .chunk(100) + .map(userIds => { + return apiToFuture( + this.api.models.users.get({ + fields: { + id: true, + displayName: true, + userCredentials: { username: true, userRoles: { authorities: true } }, + }, + filter: { username: { in: userIds } }, + paging: false, + }) + ); + }) + .value(); + + return Future.parallel($requests, { concurrency: 3 }).map(allResponses => { + return allResponses.flat().flatMap(response => { + return response.objects.map(d2User => { + return User.create({ + id: d2User.id, + name: d2User.displayName, + username: d2User.userCredentials.username, + userGroups: [], + userRoles: [], + isSuperAdmin: d2User.userCredentials.userRoles.some(role => role.authorities.includes("ALL")), + }); + }); + }); + }); + } + + public getCurrent(): FutureData { + return apiToFuture(this.api.currentUser.get({ fields: userFields })).map(d2User => { + const res = this.buildUser(d2User); + return res; + }); + } + + private buildUser(d2User: D2User) { + return new User({ + id: d2User.id, + name: d2User.displayName, + userGroups: d2User.userGroups, + ...d2User.userCredentials, + isSuperAdmin: d2User.userCredentials.userRoles.some(role => role.authorities.includes("ALL")), + }); + } +} + +const userFields = { + id: true, + displayName: true, + userGroups: { id: true, name: true, code: true }, + userCredentials: { + username: true, + userRoles: { id: true, name: true, authorities: true }, + }, +} as const; + +type D2User = MetadataPick<{ users: { fields: typeof userFields } }>["users"][number]; diff --git a/src/data/UserSharingD2Repository.ts b/src/data/UserSharingD2Repository.ts new file mode 100644 index 0000000..796a10c --- /dev/null +++ b/src/data/UserSharingD2Repository.ts @@ -0,0 +1,31 @@ +import { UserSharing } from "../domain/entities/UserSharing"; +import { FutureData } from "../domain/generic/Future"; +import { UserSharingRepository } from "../domain/repositories/UserSharingRepository"; +import { D2Api } from "../types/d2-api"; +import { apiToFuture } from "./api-futures"; + +export class UserSharingD2Repository implements UserSharingRepository { + constructor(private api: D2Api) {} + + get(query: string): FutureData { + const options = { + fields: { id: true, displayName: true, userCredentials: { username: true }, code: true }, + filter: { displayName: { ilike: query } }, + }; + + return apiToFuture(this.api.metadata.get({ users: options, userGroups: options })).map(userSearch => ({ + users: userSearch.users.map(user => ({ + id: user.id, + name: user.displayName, + username: user.userCredentials.username, + })), + userGroups: userSearch.userGroups + .filter(userGroup => Boolean(userGroup.code)) + .map(userGroup => ({ + id: userGroup.id, + name: userGroup.displayName, + code: userGroup.code, + })), + })); + } +} diff --git a/src/data/api-futures.ts b/src/data/api-futures.ts new file mode 100644 index 0000000..5bca3b3 --- /dev/null +++ b/src/data/api-futures.ts @@ -0,0 +1,18 @@ +import { Future, FutureData } from "../domain/generic/Future"; +import { CancelableResponse } from "../types/d2-api"; + +export function apiToFuture(res: CancelableResponse): FutureData { + return Future.fromComputation((resolve, reject) => { + res.getData() + .then(resolve) + .catch((err: unknown) => { + if (err instanceof Error) { + reject(err); + } else { + console.error("apiToFuture:uncatched", err); + reject(new Error("Unknown error")); + } + }); + return res.cancel; + }); +} diff --git a/src/data/common/DataSetD2Repository.ts b/src/data/common/DataSetD2Repository.ts index f9df8ee..8ba9c0f 100644 --- a/src/data/common/DataSetD2Repository.ts +++ b/src/data/common/DataSetD2Repository.ts @@ -1,8 +1,10 @@ import { Id } from "../../domain/common/entities/Base"; -import { DataSet } from "../../domain/common/entities/DataSet"; +import { DataSet, getAllowedPeriodType } from "../../domain/common/entities/DataSet"; import { OrgUnit } from "../../domain/common/entities/OrgUnit"; import { DataSetRepository } from "../../domain/common/repositories/DataSetRepository"; -import { D2Api } from "../../types/d2-api"; +import { FutureData } from "../../domain/generic/Future"; +import { D2Api, MetadataPick } from "../../types/d2-api"; +import { apiToFuture } from "../api-futures"; export class DataSetD2Repository implements DataSetRepository { constructor(private api: D2Api) {} @@ -10,10 +12,7 @@ export class DataSetD2Repository implements DataSetRepository { async getByNameOrCode(nameOrCode: string): Promise { return this.api.metadata .get({ - dataSets: { - fields: { id: true, code: true, name: true, organisationUnits: { id: true, name: true } }, - filter: { identifiable: { eq: nameOrCode } }, - }, + dataSets: { fields: dataSetListFields, filter: { identifiable: { eq: nameOrCode } } }, }) .getData() .then(response => { @@ -25,6 +24,7 @@ export class DataSetD2Repository implements DataSetRepository { code: dataSet.code, id: dataSet.id, name: dataSet.name, + periodType: getAllowedPeriodType(dataSet.periodType), organisationUnits: dataSet.organisationUnits.map( (ou): OrgUnit => ({ id: ou.id, @@ -40,12 +40,7 @@ export class DataSetD2Repository implements DataSetRepository { async getById(id: Id): Promise { return this.api.metadata - .get({ - dataSets: { - fields: dataSetFields, - filter: { id: { eq: id } }, - }, - }) + .get({ dataSets: { fields: dataSetFields, filter: { id: { eq: id } } } }) .getData() .then(response => { return response.dataSets.map(d2DataSet => { @@ -53,6 +48,7 @@ export class DataSetD2Repository implements DataSetRepository { id: d2DataSet.id, name: d2DataSet.name, code: d2DataSet.code, + periodType: getAllowedPeriodType(d2DataSet.periodType), dataElements: d2DataSet.dataSetElements.map(d2DataElement => { return { id: d2DataElement.dataElement.id, @@ -75,12 +71,42 @@ export class DataSetD2Repository implements DataSetRepository { }); }); } + + getByCodes(codes: string[]): FutureData { + return apiToFuture( + this.api.metadata.get({ dataSets: { fields: dataSetListFields, filter: { code: { in: codes } } } }) + ).map(response => { + return this.buildDataSet(response.dataSets); + }); + } + + private buildDataSet(d2DataSets: D2DataSetListField[]): DataSet[] { + return d2DataSets.map((dataSet): DataSet => { + return { + dataElements: [], + code: dataSet.code, + id: dataSet.id, + name: dataSet.name, + periodType: getAllowedPeriodType(dataSet.periodType), + organisationUnits: dataSet.organisationUnits.map( + (ou): OrgUnit => ({ + id: ou.id, + name: ou.name, + path: "", + children: [], + level: 0, + }) + ), + }; + }); + } } const dataSetFields = { id: true, name: true, code: true, + periodType: true, dataSetElements: { dataElement: { id: true, @@ -105,3 +131,15 @@ const dataSetFields = { level: true, }, }; + +const dataSetListFields = { + id: true, + code: true, + name: true, + organisationUnits: { id: true, name: true }, + periodType: true, +} as const; + +type D2DataSetListField = MetadataPick<{ + dataSets: { fields: typeof dataSetListFields }; +}>["dataSets"][number]; diff --git a/src/data/common/DataValuesD2Repository.ts b/src/data/common/DataValuesD2Repository.ts index 383588b..c03f302 100644 --- a/src/data/common/DataValuesD2Repository.ts +++ b/src/data/common/DataValuesD2Repository.ts @@ -22,7 +22,10 @@ export class DataValuesD2Repository implements DataValuesRepository { const res = await res$.getData(); - const dataElementIds = res.dataValues.map(dataValue => dataValue.dataElement); + const dataElementIds = _(res.dataValues) + .map(dataValue => dataValue.dataElement) + .uniq() + .value(); const chunkSize = 100; diff --git a/src/data/common/Dhis2ConfigRepository.ts b/src/data/common/Dhis2ConfigRepository.ts index f9e6629..9e5f04d 100644 --- a/src/data/common/Dhis2ConfigRepository.ts +++ b/src/data/common/Dhis2ConfigRepository.ts @@ -1,15 +1,9 @@ import _ from "lodash"; -import { AppSettings } from "../../domain/common/entities/AppSettings"; -import { keyById, NamedRef } from "../../domain/common/entities/Base"; import { Config } from "../../domain/common/entities/Config"; import { ReportType } from "../../domain/common/entities/ReportType"; -import { User, UserDataSetAction } from "../../domain/common/entities/User"; +import { User } from "../../domain/common/entities/User"; import { ConfigRepository } from "../../domain/common/repositories/ConfigRepository"; -import { D2Api, Id } from "../../types/d2-api"; -import { Maybe } from "../../types/utils"; -import { getReportType } from "../../webapp/utils/reportType"; -import { approvalReportAccess } from "../ApprovalReportData"; -import { D2ApprovalReport } from "../D2ApprovalReport"; +import { D2Api } from "../../types/d2-api"; import { malDataSetCodes } from "../reports/mal-data-approval/constants/MalDataApprovalConstants"; export const SQL_VIEW_DATA_COMMENTS_NAME = "NHWA Data Comments"; @@ -113,136 +107,40 @@ const base: Record = { }; export class Dhis2ConfigRepository implements ConfigRepository { - private d2ApprovalReport: D2ApprovalReport; - - constructor(private api: D2Api, private type: ReportType) { - this.d2ApprovalReport = new D2ApprovalReport(); - } + constructor(private api: D2Api) {} async get(): Promise { - const appSettings = this.d2ApprovalReport.get(); const { serverTimeZoneId } = await this.api.system.info.getData(); - const { dataSets, sqlViews: existedSqlViews, dataApprovalWorkflows } = await this.getMetadata(appSettings); + const { sqlViews: existedSqlViews, dataApprovalWorkflows } = await this.getMetadata(); const currentUser = await this.getCurrentUser(); - const userGroups = currentUser.userGroups.map(group => group.name); - const dataSetsToShow = _(dataSets) - .map(d2DataSet => { - const dataSetAccess = approvalReportAccess.dataSets[d2DataSet.code]; - if (!dataSetAccess) return undefined; - - if (currentUser.isAdmin) return d2DataSet; - - return _(dataSetAccess.read.userGroups).intersection(userGroups).value().length > 0 - ? d2DataSet - : undefined; - }) - .compact() - .value(); - const filteredDataSets = getFilteredDataSets(dataSets); + // const filteredDataSets = getFilteredDataSets([]); const sqlViews = existedSqlViews.reduce((acc, sqlView) => { return { ...acc, [sqlView.name]: sqlView }; }, {}); - const pairedDataElements = getPairedMapping(filteredDataSets); - const orgUnitList = getPairedOrgunitsMapping(filteredDataSets); + // const pairedDataElements = getPairedMapping(filteredDataSets); + // const orgUnitList = getPairedOrgunitsMapping(filteredDataSets); const currentYear = new Date().getFullYear(); return { timeZoneId: serverTimeZoneId, - dataSets: keyById(dataSetsToShow), - currentUser: { - ...currentUser, - dataSets: _(dataSetsToShow) - .map((dataSet): Maybe => { - const accessDataSet = approvalReportAccess.dataSets[dataSet.code]; - if (!accessDataSet) return undefined; - return [ - dataSet.id, - { - complete: - _(accessDataSet.complete.userGroups).intersection(userGroups).value().length > 0, - incomplete: - _(accessDataSet.incomplete.userGroups).intersection(userGroups).value().length > 0, - monitoring: - _(accessDataSet.monitoring.userGroups).intersection(userGroups).value().length > 0, - read: _(accessDataSet.read.userGroups).intersection(userGroups).value().length > 0, - revoke: _(accessDataSet.revoke.userGroups).intersection(userGroups).value().length > 0, - submit: _(accessDataSet.submit.userGroups).intersection(userGroups).value().length > 0, - approve: - _(accessDataSet.approve.userGroups).intersection(userGroups).value().length > 0, - }, - ]; - }) - .compact() - .fromPairs() - .value(), - }, + dataSets: {}, + currentUser: currentUser, sqlViews, - pairedDataElementsByDataSet: pairedDataElements, - orgUnits: orgUnitList, + pairedDataElementsByDataSet: {}, + orgUnits: [], sections: undefined, sectionsByDataSet: undefined, years: _.range(currentYear - 10, currentYear + 1).map(n => n.toString()), approvalWorkflow: dataApprovalWorkflows, - appSettings: appSettings, }; } - private async getDataSetsConfigured(): Promise { - const appSettings = this.d2ApprovalReport.get(); - - const dataSetsCodes = Object(appSettings.dataSets).keys(); - - if (dataSetsCodes.length === 0) throw new Error("No data sets configured"); - - const response = await this.api.models.dataSets - .get({ - fields: { - id: true, - code: true, - displayName: toName, - dataSetElements: { dataElement: { id: true, name: true } }, - organisationUnits: { id: true }, - }, - filter: { code: { in: dataSetsCodes } }, - }) - .getData(); - - return _(response.objects) - .map(d2DataSet => { - const currentDataSet = appSettings.dataSets[d2DataSet.code]; - if (!currentDataSet) { - console.warn(`No dataSet config. found with code: ${d2DataSet.code}`); - return undefined; - } - - return [d2DataSet.code, currentDataSet]; - }) - .compact() - .fromPairs() - .value(); - } - - getMetadata(appSettings: AppSettings) { - const dataSetCodes = Object.keys(appSettings.dataSets); - if (dataSetCodes.length === 0) throw new Error("No data sets configured"); - + getMetadata() { const { constantCode, sqlViewNames, approvalWorkflows } = base.mal; const metadata$ = this.api.metadata.get({ - dataSets: { - fields: { - id: true, - code: true, - displayName: toName, - dataSetElements: { - dataElement: { id: true, name: true }, - }, - organisationUnits: { id: true }, - }, - filter: { code: { in: dataSetCodes } }, - }, constants: { fields: { description: true }, filter: { code: { eq: constantCode } }, @@ -272,7 +170,7 @@ export class Dhis2ConfigRepository implements ConfigRepository { username: true, userRoles: { id: true, name: true, authorities: true }, }, - userGroups: { id: true, name: true }, + userGroups: { id: true, name: true, code: true }, }, }) .getData(); @@ -297,81 +195,8 @@ export class Dhis2ConfigRepository implements ConfigRepository { } } -interface DataSet { - id: Id; - dataSetElements: Array<{ dataElement: NamedRef }>; - organisationUnits: Array<{ id: Id }>; -} - -function getNameOfDataElementWithValue(name: string): string { - const s = "NHWA_" + name.replace(/NHWA_Comment of /, ""); - return s.replace(" - ", " for "); -} - -function getCleanName(name: string): string { - return name - .replace(/[^\w]$/, "") // Remove trailing non-alphanumic characters - .replace(/\s+/g, " ") // Replace &nbps (x160) characters by normal spaces - .trim() - .toLowerCase(); -} - -function getPairedMapping(dataSets: DataSet[]): Config["pairedDataElementsByDataSet"] { - const dataElementsByName = _(dataSets) - .flatMap(dataSet => dataSet.dataSetElements) - .map(dse => dse.dataElement) - .keyBy(de => getCleanName(de.name)) - .value(); - - return _(dataSets) - .map(dataSet => { - const mapping = getMappingForDataSet(dataSet, dataElementsByName); - return [dataSet.id, mapping] as [string, typeof mapping]; - }) - .fromPairs() - .value(); -} - -function getPairedOrgunitsMapping(dataSets: DataSet[]) { - const orgUnitList = _(dataSets) - .flatMap(dataSet => dataSet.organisationUnits) - .map(ou => ou.id) - .value(); - - return orgUnitList; -} - -function getMappingForDataSet(dataSet: DataSet, dataElementsByName: Record) { - return _(dataSet.dataSetElements) - .map(dse => dse.dataElement) - .filter(de => de.name.startsWith("NHWA_Comment of")) - .map(de => { - const name = getNameOfDataElementWithValue(de.name); - const cleanName = getCleanName(name); - const valueDataElement = dataElementsByName[cleanName]; - if (!valueDataElement) { - console.error(`Value data element not found for comment:\n ${name}`); - return null; - } else { - return { dataValueVal: valueDataElement.id, dataValueComment: de.id }; - } - }) - .compact() - .value(); -} - -function getFilteredDataSets(dataSets: DataSet[]): DataSet[] { - const type = getReportType(); - const { namePrefix, nameExcluded } = base[type].dataSets; - - if (!namePrefix || !nameExcluded) return dataSets; - return dataSets.filter(({ name }) => name.startsWith(namePrefix) && !name.match(nameExcluded)); -} - const toName = { $fn: { name: "rename", to: "name" } } as const; -type D2UserDataSetAccess = [string, UserDataSetAction]; - const userOrgUnitFields = { id: true, displayName: toName, diff --git a/src/data/reports/glass-data-submission/GLASSDataSubmissionDefaultRepository.ts b/src/data/reports/glass-data-submission/GLASSDataSubmissionDefaultRepository.ts deleted file mode 100644 index b6688c8..0000000 --- a/src/data/reports/glass-data-submission/GLASSDataSubmissionDefaultRepository.ts +++ /dev/null @@ -1,1412 +0,0 @@ -import _ from "lodash"; -import { paginate, PaginatedObjects } from "../../../domain/common/entities/PaginatedObjects"; -import { - ApprovalIds, - GLASSDataSubmissionItem, - GLASSDataSubmissionItemIdentifier, - GLASSDataSubmissionModule, - EARDataSubmissionItem, - EARSubmissionItemIdentifier, - getUserModules, - Status, - Module, -} from "../../../domain/reports/glass-data-submission/entities/GLASSDataSubmissionItem"; -import { - EARDataSubmissionOptions, - GLASSDataSubmissionOptions, - GLASSDataSubmissionRepository, -} from "../../../domain/reports/glass-data-submission/repositories/GLASSDataSubmissionRepository"; -import { D2Api, SelectedPick } from "../../../types/d2-api"; -import { DataStoreStorageClient } from "../../common/clients/storage/DataStoreStorageClient"; -import { StorageClient } from "../../common/clients/storage/StorageClient"; -import { Instance } from "../../common/entities/Instance"; -import { promiseMap } from "../../../utils/promises"; -import { Id, NamedRef, Ref } from "../../../domain/common/entities/Base"; -import { - earStatusItems, - statusItems, -} from "../../../webapp/reports/glass-data-submission/glass-data-submission-list/Filters"; -import { Namespaces } from "../../common/clients/storage/Namespaces"; -import { Config } from "../../../domain/common/entities/Config"; -import { generateUid } from "../../../utils/uid"; -import { OrgUnitWithChildren } from "../../../domain/reports/glass-data-submission/entities/OrgUnit"; -import { D2TrackerEventSchema, TrackerEventsResponse } from "@eyeseetea/d2-api/api/trackerEvents"; -import { - D2TrackedEntityInstanceToPost, - D2TrackerTrackedEntitySchema, - TrackedEntitiesGetResponse, -} from "@eyeseetea/d2-api/api/trackerTrackedEntities"; - -interface CompleteDataSetRegistrationsResponse { - completeDataSetRegistrations: Registration[] | undefined; -} - -interface Registration { - period: string; - dataSet: string; - organisationUnit: string; - attributeOptionCombo: string; - date: string; - storedBy: string; - completed: boolean; -} - -interface GLASSDataSubmissionItemUpload extends GLASSDataSubmissionItemIdentifier { - dataSubmission: string; - status: "UPLOADED" | "IMPORTED" | "VALIDATED" | "COMPLETED"; -} - -interface MessageConversations { - messageConversations: { - id: Id; - displayName: string; - }[]; -} - -type DataValueType = { - dataElement: string; - period: string; - orgUnit: string; - value: string; - [key: string]: string; -}; - -type DataValueSetsType = { - dataSet: string; - period: string; - orgUnit: string; - completeDate?: string; - dataValues: DataValueType[]; -}; - -export class GLASSDataSubmissionDefaultRepository implements GLASSDataSubmissionRepository { - private storageClient: StorageClient; - private globalStorageClient: StorageClient; - - constructor(private api: D2Api) { - const instance = new Instance({ url: this.api.baseUrl }); - this.storageClient = new DataStoreStorageClient("user", instance); - this.globalStorageClient = new DataStoreStorageClient("global", instance); - } - - async get( - options: GLASSDataSubmissionOptions, - namespace: string - ): Promise> { - const { paging, sorting, module: selectedModule, periods } = options; - if (!selectedModule) return emptyPage; - - const modules = await this.getModules(); - const objects = await this.getDataSubmissionObjects(namespace); - const uploads = await this.getUploads(); - - const dataSubmissions = await this.getDataSubmissions(objects, modules, uploads, selectedModule, periods); - const filteredRows = this.getFilteredRows(dataSubmissions, options); - const { pager, objects: rowsInPage } = paginate(filteredRows, paging, sorting); - - return { - pager, - objects: rowsInPage, - }; - } - - private async getUploads() { - return ( - (await this.globalStorageClient.getObject( - Namespaces.DATA_SUBMISSSIONS_UPLOADS - )) ?? [] - ); - } - - private async getDataSubmissionObjects(namespace: string) { - return (await this.globalStorageClient.getObject(namespace)) ?? []; - } - - private async getDataSubmissions( - objects: GLASSDataSubmissionItem[], - modules: GLASSDataSubmissionModule[], - uploads: GLASSDataSubmissionItemUpload[], - selectedModule: Module, - periods: string[] - ): Promise { - const enrolledCountries = await this.getEnrolledCountries(); - const amrFocalPointProgramId = await this.getAMRFocalPointProgramId(); - const dataSubmissionItems = await this.getDataSubmissionIdentifiers( - amrFocalPointProgramId, - enrolledCountries.join(";"), - modules, - periods - ); - - const moduleQuestionnaires = - modules - .find(module => module.id === selectedModule) - ?.questionnaires.map(questionnaire => questionnaire.id) ?? []; - - const registrations = await this.getRegistrations(dataSubmissionItems, moduleQuestionnaires); - - const dataSubmissions: GLASSDataSubmissionItem[] = dataSubmissionItems.map(dataSubmissionItem => { - const dataSubmission = objects.find( - object => - object.period === dataSubmissionItem.period && - object.orgUnit === dataSubmissionItem.orgUnit && - object.module === dataSubmissionItem.module - ); - - const key = getRegistrationKey({ - orgUnitId: dataSubmissionItem.orgUnit, - period: dataSubmissionItem.period, - }); - const match = registrations[key]; - - const submissionStatus = - statusItems.find(item => item.value === dataSubmission?.status)?.text ?? "Not completed"; - const dataSubmissionPeriod = - modules.find(module => dataSubmissionItem.module === module.id)?.dataSubmissionPeriod ?? "YEARLY"; - const dataSetsUploaded = getDatasetsUploaded(uploads, dataSubmission); - - return { - ...dataSubmissionItem, - ...match, - ...dataSubmission, - submissionStatus, - dataSetsUploaded, - dataSubmissionPeriod, - id: dataSubmission?.id ?? generateUid(), - period: dataSubmissionItem.period.slice(0, 4), - orgUnitName: match?.orgUnitName ?? "", - questionnaireCompleted: match?.questionnaireCompleted ?? false, - status: dataSubmission?.status ?? "NOT_COMPLETED", - statusHistory: dataSubmission?.statusHistory ?? [], - from: dataSubmission?.from ?? null, - to: dataSubmission?.to ?? null, - creationDate: dataSubmission?.creationDate ?? new Date().toISOString(), - }; - }); - - return dataSubmissions; - } - - private async getEnrolledCountries(): Promise { - const userOrgUnits = await this.getUserOrgUnits(); - const orgUnitsWithChildren = await this.getOUsWithChildren(userOrgUnits); - - const countriesOutsideNARegion = await this.getCountriesOutsideNARegion(); - const enrolledCountries = orgUnitsWithChildren.filter(orgUnit => countriesOutsideNARegion.includes(orgUnit)); - - return enrolledCountries; - } - - private async getOUsWithChildren(userOrgUnits: string[]): Promise { - const { organisationUnits } = await this.api.metadata - .get({ - organisationUnits: { - fields: { - id: true, - level: true, - children: { - id: true, - level: true, - children: { - id: true, - level: true, - }, - }, - }, - filter: { - level: { - eq: "1", - }, - }, - }, - }) - .getData(); - - const orgUnitsWithChildren = _(userOrgUnits) - .flatMap(ou => { - const res = flattenNodes(flattenNodes(organisationUnits).filter(res => res.id === ou)).map( - node => node.id - ); - return res; - }) - .uniq() - .value(); - - return orgUnitsWithChildren; - } - - private async getUserOrgUnits(): Promise { - return (await this.api.get<{ organisationUnits: Ref[] }>("/me").getData()).organisationUnits.map(ou => ou.id); - } - - private async getDataSubmissionIdentifiers( - program: string, - orgUnit: string, - modules: GLASSDataSubmissionModule[], - dataSubmissionPeriods: string[] - ): Promise { - const instances = await this.getAllTrackedEntities(program, orgUnit); - - const orgUnitModules: { orgUnit: string; module: string }[] = _(instances) - .map(instance => { - const module = instance.attributes.map(attribute => attribute.value)[0] ?? ""; - - return { - orgUnit: instance.orgUnit, - module: moduleMapping[module] ?? "", - }; - }) - .uniqBy(instance => `${instance.orgUnit}_${instance.module}`) - .value(); - - return orgUnitModules.flatMap(orgUnitModule => { - return dataSubmissionPeriods.map(dataSubmissionPeriod => ({ - ...orgUnitModule, - period: dataSubmissionPeriod, - module: modules.find(module => module.name === orgUnitModule.module)?.id ?? "", - })); - }); - } - - private async getCountriesOutsideNARegion(): Promise { - const { organisationUnits: kosovoOu } = await this.api.metadata - .get({ - organisationUnits: { - fields: { - id: true, - }, - filter: { - name: { - eq: "Kosovo", - }, - level: { - eq: "3", - }, - "parent.code": { - eq: "NA", - }, - }, - }, - }) - .getData(); - - const { organisationUnits } = await this.api.metadata - .get({ - organisationUnits: { - fields: { - id: true, - }, - filter: { - level: { - eq: "3", - }, - "parent.code": { - ne: "NA", - }, - }, - paging: false, - }, - }) - .getData(); - - return _.concat(organisationUnits, kosovoOu).map(orgUnit => orgUnit.id); - } - - private async getAMRFocalPointProgramId(): Promise { - const { programs } = await this.api.metadata - .get({ - programs: { - fields: { - id: true, - }, - filter: { - name: { - eq: "AMR - Focal Point", - }, - }, - }, - }) - .getData(); - - const programId = _.first(programs)?.id ?? ""; - - return programId; - } - - async getUserModules(config: Config): Promise { - const modules = await this.getModules(); - - return getUserModules(modules, config.currentUser); - } - - async getEAR( - options: EARDataSubmissionOptions, - namespace: string - ): Promise> { - const { paging, sorting, orgUnitIds, from, to, submissionStatus } = options; - - const userOrgUnits = (await this.api.get<{ organisationUnits: Ref[] }>("/me").getData()).organisationUnits.map( - ou => ou.id - ); - const { organisationUnits } = await this.api - .get( - "/metadata?organisationUnits:fields=children[children[id,level],id,level],id,level&organisationUnits:filter=level:eq:1" - ) - .getData(); - const orgUnitsWithChildren = _(userOrgUnits) - .flatMap(ou => { - const res = flattenNodes(flattenNodes(organisationUnits).filter(res => res.id === ou)).map( - node => node.id - ); - return res; - }) - .uniq() - .value(); - - const objects = (await this.globalStorageClient.getObject(namespace)) ?? []; - const rows = objects.filter(object => orgUnitsWithChildren.includes(object.orgUnit.id)); - - const filteredRows = rows - .filter(row => { - return ( - (_.isEmpty(orgUnitIds) || !row.orgUnit ? row : orgUnitIds.includes(row.orgUnit.id)) && - !!(!from && !to - ? row - : (from && new Date(row.creationDate) >= from) || (to && new Date(row.creationDate) <= to)) && - (!submissionStatus ? row : row.status === submissionStatus) - ); - }) - .map(row => { - const submissionStatus = earStatusItems.find(item => item.value === row.status)?.text ?? ""; - return { ...row, submissionStatus }; - }); - - return paginate(filteredRows, paging, sorting); - } - - private async getRegistrations( - items: GLASSDataSubmissionItemIdentifier[], - moduleQuestionnaires: Id[] - ): Promise> { - const orgUnitIds = _.uniq(items.map(obj => obj.orgUnit)); - const periods = _.uniq(items.map(obj => obj.period)); - const orgUnitsById = await this.getOrgUnits(orgUnitIds); - const apiRegistrations = !_(moduleQuestionnaires).compact().isEmpty() - ? await this.getApiRegistrations({ - orgUnitIds, - periods, - moduleQuestionnaires, - }) - : []; - - const registrationsByOrgUnitPeriod = _.keyBy(apiRegistrations, apiReg => - getRegistrationKey({ orgUnitId: apiReg.organisationUnit, period: apiReg.period }) - ); - - return _(items) - .map((item): RegistrationItemBase => { - const key = getRegistrationKey({ orgUnitId: item.orgUnit, period: item.period }); - const registration = registrationsByOrgUnitPeriod[key]; - const orgUnitName = orgUnitsById[item.orgUnit]?.name || "-"; - - return { - orgUnit: item.orgUnit, - period: item.period, - orgUnitName: orgUnitName, - questionnaireCompleted: registration?.completed ?? false, - }; - }) - .keyBy(item => getRegistrationKey({ orgUnitId: item.orgUnit, period: item.period })) - .value(); - } - - private async getApiRegistrations(options: { - orgUnitIds: Id[]; - periods: string[]; - moduleQuestionnaires: Id[]; - }): Promise { - const responses = options.moduleQuestionnaires.flatMap(dataSet => - _.chunk(options.orgUnitIds, 300).map(orgUnitIdsGroups => - this.api.get("/completeDataSetRegistrations", { - dataSet: dataSet, - orgUnit: orgUnitIdsGroups, - period: options.periods, - }) - ) - ); - - return _(await promiseMap(responses, response => response.getData())) - .flatMap(r => r.completeDataSetRegistrations || []) - .value(); - } - - private async getOrgUnits(orgUnitIds: Id[]): Promise> { - const responses = _.chunk(orgUnitIds, 300).map(orgUnitIdsGroup => - this.api.metadata.get({ - organisationUnits: { - fields: { id: true, displayName: true }, - filter: { id: { in: orgUnitIdsGroup } }, - }, - }) - ); - - const metadataList = await promiseMap(responses, response => response.getData()); - - return _(metadataList) - .flatMap(metadata => - metadata.organisationUnits.map(orgUnit => ({ - id: orgUnit.id, - name: orgUnit.displayName, - })) - ) - .keyBy(orgUnit => orgUnit.id) - .value(); - } - - async getColumns(namespace: string): Promise { - const columns = await this.storageClient.getObject(namespace); - - return columns ?? []; - } - - async saveColumns(namespace: string, columns: string[]): Promise { - return this.storageClient.saveObject(namespace, columns); - } - - async dhis2MessageCount(): Promise { - const { messageConversations } = - (await this.api - .get("/messageConversations.json?filter=read%3Aeq%3Afalse") - .getData()) ?? []; - - return messageConversations.length; - } - - private async getModules(): Promise { - const modules = - (await this.globalStorageClient.getObject( - Namespaces.DATA_SUBMISSSIONS_MODULES - )) ?? []; - - return _(modules) - .map(module => ({ - ...module, - userGroups: { ...module.userGroups, approveAccess: module.userGroups.approveAccess ?? [] }, - })) - .value(); - } - - private getFilteredRows( - rows: GLASSDataSubmissionItem[], - options: GLASSDataSubmissionOptions - ): GLASSDataSubmissionItem[] { - const { orgUnitIds, module, dataSubmissionPeriod, periods, quarters, completionStatus, submissionStatus } = - options; - const quarterPeriods = _.flatMap(periods, year => quarters.map(quarter => `${year}${quarter}`)); - - return rows.filter(row => { - const isInOrgUnit = !!(_.isEmpty(orgUnitIds) || !row.orgUnit ? row : orgUnitIds.includes(row.orgUnit)); - const isInModule = row.module === module; - - const isDataSubmissionYearly = dataSubmissionPeriod === "YEARLY"; - const isInPeriod = isDataSubmissionYearly - ? periods.includes(row.period) - : quarterPeriods.includes(row.period); - - const isCompleted = !!(completionStatus !== undefined - ? row.questionnaireCompleted === completionStatus - : row); - const isSubmitted = !!(!submissionStatus ? row : row.status === submissionStatus); - - return isInOrgUnit && isInModule && isInPeriod && isCompleted && isSubmitted; - }); - } - - private async getNotificationText( - items: GLASSDataSubmissionItemIdentifier[], - modules: GLASSDataSubmissionModule[], - status: string - ) { - const glassModule = modules.find(module => module.id === items[0]?.module)?.name; - const orgUnitIds = _(items) - .map(({ orgUnit }) => orgUnit) - .compact() - .uniq() - .value(); - const orgUnitsById = await this.getOrgUnits(orgUnitIds); - const itemsWithCountry = items.map(item => { - const country = item.orgUnit ? orgUnitsById[item.orgUnit]?.name || "-" : undefined; - return { period: item.period, country }; - }); - const multipleItems = items.length > 1; - - const text = `The data ${ - multipleItems ? "submissions" : "submission" - } for ${glassModule} module for${itemsWithCountry.map( - item => ` year ${item.period} and country ${item.country}` - )} ${multipleItems ? "have" : "has"} changed to ${status.toUpperCase()}.`; - - return text; - } - - private getEARNotificationText( - signals: EARSubmissionItemIdentifier[], - modules: GLASSDataSubmissionModule[], - status: string - ) { - const earModule = modules.find(module => module.id === signals[0]?.module)?.name; - - const text = signals - .map( - signal => - `${ - signal.levelOfConfidentiality === "CONFIDENTIAL" ? "Confidential" : "Non-Confidential" - } Signal for ${earModule} module and country ${ - signal.orgUnitName - } ${status} at ${new Date().toISOString()}` - ) - .join("\n"); - - return text; - } - - private async getRecipientUsers(items: GLASSDataSubmissionItemIdentifier[], modules: GLASSDataSubmissionModule[]) { - const userGroups = _.flatMap( - _.compact( - items.map( - item => - modules.find(mod => mod.id === item.module && !_.isEmpty(mod.userGroups))?.userGroups - .captureAccess - ) - ) - ).map(({ id }) => id); - - const orgUnits = _( - await promiseMap( - items, - async item => - await this.api.get(`/organisationUnits/${item.orgUnit}?fields=ancestors,id`).getData() - ) - ) - .flatMapDeep(obj => [obj.id, _.map(obj.ancestors, "id")]) - .flatten() - .value(); - - const { objects: recipientUsers } = await this.api.models.users - .get({ - fields: { - id: true, - }, - filter: { - "organisationUnits.id": { in: orgUnits }, - "userGroups.id": { in: userGroups }, - }, - }) - .getData(); - - return recipientUsers; - } - - private async getEARRecipientUsers(items: EARSubmissionItemIdentifier[], modules: GLASSDataSubmissionModule[]) { - const userGroups = _.flatMap( - _.compact( - items.map( - item => - modules.find(mod => mod.id === item.module && !_.isEmpty(mod.userGroups))?.userGroups.readAccess - ) - ) - ).map(({ id }) => id); - - const orgUnits = _( - await promiseMap( - items, - async item => - await this.api.get(`/organisationUnits/${item.orgUnitId}?fields=ancestors,id`).getData() - ) - ) - .flatMapDeep(obj => [obj.id, _.map(obj.ancestors, "id")]) - .flatten() - .value(); - - const { objects: recipientUsers } = await this.api.models.users - .get({ - fields: { - id: true, - }, - filter: { - "organisationUnits.id": { in: orgUnits }, - "userGroups.id": { in: userGroups }, - }, - }) - .getData(); - - return recipientUsers; - } - - private async getDataSetsValue(dataSet: string, orgUnit: string, period: string) { - return await this.api - .get("/dataValueSets", { - dataSet, - orgUnit, - period, - }) - .getData(); - } - - private async getDSDataElements(dataSet: string) { - return await this.api - .get(`/dataSets/${dataSet}`, { fields: "dataSetElements[dataElement[id,name]]" }) - .getData(); - } - - private async getTrackerProgramEvents( - program: string, - orgUnit: string, - isEGASPModule: boolean - ): Promise { - const response: TrackerEventsResponse = await this.api.tracker.events - .get({ - fields: eventFields, - program: program, - orgUnit: orgUnit, - ouMode: isEGASPModule ? "DESCENDANTS" : "SELECTED", - skipPaging: true, - }) - .getData(); - - return response.instances; - } - - private async createTrackerProgramEventsByChunks(eventsToPost: D2TrackerEvent[]): Promise { - await promiseMap(_.chunk(eventsToPost, 100), async eventsGroup => { - return await this.api.tracker - .post({ importStrategy: "CREATE" }, { events: _.reject(eventsGroup, _.isEmpty) }) - .getData(); - }); - } - - private async importTrackedEntitiesByStrategy( - trackedEntities: D2TrackedEntityInstanceToPost[], - importStrategy: "CREATE" | "UPDATE" | "CREATE_AND_UPDATE" | "DELETE", - async?: boolean - ): Promise { - if (async) { - await this.api.tracker - .postAsync({ importStrategy: importStrategy }, { trackedEntities: trackedEntities }) - .getData(); - } else { - await this.api.tracker - .post({ importStrategy: importStrategy }, { trackedEntities: trackedEntities }) - .getData(); - } - } - - private async importEventsByStrategy( - events: D2TrackerEvent[], - importStrategy: "CREATE" | "UPDATE" | "CREATE_AND_UPDATE" | "DELETE", - async?: boolean - ): Promise { - if (async) { - await this.api.tracker.postAsync({ importStrategy: importStrategy }, { events: events }).getData(); - } else { - await this.api.tracker.post({ importStrategy: importStrategy }, { events: events }).getData(); - } - } - - private getTEIsWithProgramStages( - trackedEntityInstances: D2TrackerEntity[], - programStages: string[] - ): D2TrackerEntity[] { - return trackedEntityInstances.filter(trackedEntity => { - const teiProgramStage = trackedEntity.enrollments[0]?.events[0]?.programStage ?? ""; - - return programStages.includes(teiProgramStage); - }); - } - - private getTrackedEntitiesOfPage(params: { - orgUnit: Id; - page: number; - pageSize: number; - program: Id; - period?: string; - trackedEntity?: string; - totalPages?: boolean; - enrollmentEnrolledAfter?: string; - enrollmentEnrolledBefore?: string; - }): Promise> { - const { program, orgUnit, enrollmentEnrolledAfter, enrollmentEnrolledBefore, ...restParams } = params; - - return this.api.tracker.trackedEntities - .get({ - fields: trackedEntitiesFields, - program: program, - orgUnit: orgUnit, - enrollmentOccurredAfter: enrollmentEnrolledAfter, - enrollmentOccurredBefore: enrollmentEnrolledBefore, - ouMode: "SELECTED", - ...restParams, - }) - .getData(); - } - - private async getAllTrackedEntities(program: string, orgUnit: string, period?: string): Promise { - const trackedEntities: D2TrackerEntity[] = []; - const enrollmentEnrolledAfter = period ? `${period}-1-1` : undefined; - const enrollmentEnrolledBefore = period ? `${period}-12-31` : undefined; - const totalPages = true; - const pageSize = 200; - let currentPage = 1; - let response; - - try { - do { - response = await this.getTrackedEntitiesOfPage({ - program: program, - orgUnit: orgUnit, - page: currentPage, - pageSize: pageSize, - totalPages: totalPages, - enrollmentEnrolledBefore, - enrollmentEnrolledAfter, - }); - if (!response.total) { - throw new Error(`Error getting paginated events of program ${program} and organisation ${orgUnit}`); - } - trackedEntities.push(...response.instances); - currentPage++; - } while (response.page < Math.ceil((response.total as number) / pageSize)); - return trackedEntities; - } catch { - return []; - } - } - - private makeDataValuesArray( - approvalDataSetId: string, - dataValueSets: DataValueType[], - dataElementsMatchedArray: { [key: string]: any }[] - ) { - return dataValueSets.flatMap(dataValueSet => { - const dataValue = { ...dataValueSet }; - const destId = dataElementsMatchedArray.find( - dataElementsMatchedObj => dataElementsMatchedObj.origId === dataValueSet.dataElement - )?.destId; - - if (!_.isEmpty(destId) && !_.isEmpty(dataValue.value)) { - dataValue.dataElement = destId; - dataValue.dataSet = approvalDataSetId; - delete dataValue.lastUpdated; - delete dataValue.comment; - - return dataValue; - } else { - return []; - } - }); - } - - async approve( - namespace: string, - items: GLASSDataSubmissionItemIdentifier[], - signals?: EARSubmissionItemIdentifier[] - ) { - const modules = await this.getModules(); - - if (!_.isEmpty(items)) { - const objects = await this.globalStorageClient.listObjectsInCollection(namespace); - - await this.approveByModule(modules, items); - const newSubmissionValues = this.getNewSubmissionValues(items, objects, "APPROVED"); - const recipients = await this.getRecipientUsers(items, modules); - - const message = await this.getNotificationText(items, modules, "approved"); - this.sendNotifications(message, message, [], recipients); - - return await this.globalStorageClient.saveObject(namespace, newSubmissionValues); - } else if (signals) { - const objects = await this.globalStorageClient.listObjectsInCollection(namespace); - const newSubmissionValues = this.getNewEARSubmissionValues(signals, objects, "APPROVED"); - const recipients = await this.getEARRecipientUsers(signals, modules); - - const message = this.getEARNotificationText(signals, modules, "approved"); - this.sendNotifications(message, message, [], recipients); - - return await this.globalStorageClient.saveObject(namespace, newSubmissionValues); - } - } - - private async approveByModule( - modules: GLASSDataSubmissionModule[], - items: GLASSDataSubmissionItemIdentifier[] - ): Promise { - const selectedModule = modules.find(module => module.id === _.first(items)?.module)?.name ?? ""; - const { AMC, AMR, AMR_FUNGHI, AMR_INDIVIDUAL, EGASP } = moduleMapping; - - switch (selectedModule) { - case AMC: { - const amcModule = modules.find(module => module.name === AMC); - const amcPrograms = amcModule?.programs ?? []; - const amcProgramStages = amcModule?.programStages ?? []; - const amcProductRegisterProgramId = await this.getAMCProductRegisterId(); // the AMC - Product Register program is tne only tracker program in this module - - const amcEventPrograms = _.filter( - amcPrograms, - amcProgram => amcProgram.id !== amcProductRegisterProgramId - ); - const amcTrackerPrograms = _.filter( - amcPrograms, - amcProgram => amcProgram.id === amcProductRegisterProgramId - ); - - _.forEach(amcEventPrograms, async amcProgram => await this.duplicateEventProgram(amcProgram, items)); - _.forEach( - amcTrackerPrograms, - async amcProgram => await this.duplicateTrackerProgram(amcProgram, amcProgramStages, items) - ); - break; - } - case AMR: { - const amrDataSets = modules.find(module => module.name === AMR)?.dataSets ?? []; - const amrQuestionnaires = modules.find(module => module.name === AMR)?.questionnaires ?? []; - - _.forEach(amrDataSets, async amrDataSet => await this.duplicateDataSet(amrDataSet, items)); - _.forEach( - amrQuestionnaires, - async amrQuestionnaire => await this.duplicateDataSet(amrQuestionnaire, items) - ); - break; - } - case AMR_FUNGHI: { - const amrFungalModule = modules.find(module => module.name === AMR_FUNGHI); - const amrFungalQuestionnaires = amrFungalModule?.questionnaires ?? []; - const amrFungalPrograms = amrFungalModule?.programs ?? []; - const amrFungalProgramStages = amrFungalModule?.programStages ?? []; - - _.forEach( - amrFungalQuestionnaires, - async amrFungalQuestionnaire => await this.duplicateDataSet(amrFungalQuestionnaire, items) - ); - _.forEach( - amrFungalPrograms, - async amrFungalProgram => - await this.duplicateTrackerProgram(amrFungalProgram, amrFungalProgramStages, items) - ); - break; - } - case AMR_INDIVIDUAL: { - const amrIndividualModule = modules.find(module => module.name === AMR_INDIVIDUAL); - const amrIndividualQuestionnaires = amrIndividualModule?.questionnaires ?? []; - const amrIndividualPrograms = amrIndividualModule?.programs ?? []; - const amrIndividualProgramStages = amrIndividualModule?.programStages ?? []; - - _.forEach(amrIndividualQuestionnaires, async amrIndividualQuestionnaire => { - await this.duplicateDataSet(amrIndividualQuestionnaire, items); - }); - _.forEach(amrIndividualPrograms, async amrIndividualProgram => { - await this.duplicateTrackerProgram(amrIndividualProgram, amrIndividualProgramStages, items); - }); - break; - } - case EGASP: { - const egaspPrograms = modules.find(module => module.name === EGASP)?.programs ?? []; - _.forEach( - egaspPrograms, - async egaspProgram => - await this.duplicateEventProgram(egaspProgram, items, selectedModule === EGASP) - ); - break; - } - } - } - - private async getAMCProductRegisterId() { - const { objects } = await this.api.models.programs - .get({ - fields: { id: true }, - filter: { code: { eq: AMC_PRODUCT_REGISTER_CODE } }, - }) - .getData(); - - return _.first(objects)?.id ?? ""; - } - - private async duplicateDataSet(dataSet: ApprovalIds, items: GLASSDataSubmissionItemIdentifier[]) { - _.forEach(items, async item => { - const dataValueSets = (await this.getDataSetsValue(dataSet.id, item.orgUnit, item.period)).dataValues; - - if (!_.isEmpty(dataValueSets)) { - const DSDataElements: { dataSetElements: { dataElement: NamedRef }[] } = await this.getDSDataElements( - dataSet.id - ); - const ADSDataElements: { dataSetElements: { dataElement: NamedRef }[] } = await this.getDSDataElements( - dataSet.approvedId - ); - - const uniqueDataElementsIds = _.uniq(_.map(dataValueSets, "dataElement")); - const dataElementsMatchedArray = DSDataElements.dataSetElements.map(element => { - const dataElement = element.dataElement; - if (uniqueDataElementsIds.includes(dataElement.id)) { - const apvdName = dataElement.name + "-APVD"; - const ADSDataElement = ADSDataElements.dataSetElements.find( - element => element.dataElement.name === apvdName - ); - return { - origId: dataElement.id, - destId: ADSDataElement?.dataElement.id, - name: dataElement.name, - }; - } else { - return []; - } - }); - - const dataValuesToPost = this.makeDataValuesArray( - dataSet.approvedId, - dataValueSets, - dataElementsMatchedArray - ); - - await promiseMap(_.chunk(dataValuesToPost, 1000), async dataValuesGroup => { - return await this.api.dataValues - .postSet({}, { dataValues: _.reject(dataValuesGroup, _.isEmpty) }) - .getData(); - }); - } - }); - } - - private async duplicateTrackerProgram( - program: ApprovalIds, - programStages: ApprovalIds[], - items: GLASSDataSubmissionItemIdentifier[] - ): Promise { - _.forEach(items, async item => { - const allTrackedEntities: D2TrackerEntity[] = await this.getAllTrackedEntities( - program.id, - item.orgUnit, - item.period - ); - const trackedEntities = this.getTEIsWithProgramStages( - allTrackedEntities, - programStages.map(programStage => programStage.id) - ); - - if (!_.isEmpty(trackedEntities)) { - const allTrackedEntitiesInApprovedId: D2TrackerEntity[] = await this.getAllTrackedEntities( - program.approvedId, - item.orgUnit, - item.period - ); - const approvedTrackedEntities = this.getTEIsWithProgramStages( - allTrackedEntitiesInApprovedId, - programStages.map(programStage => programStage.approvedId) - ); - - if (!_.isEmpty(approvedTrackedEntities)) { - await this.importTrackedEntitiesByStrategy(approvedTrackedEntities, "DELETE"); - } - - const trackedEntitiesToPost: D2TrackerEntity[] = trackedEntities.map(trackedEntity => ({ - ...trackedEntity, - trackedEntity: "", - programOwners: trackedEntity.programOwners.map(programOwner => ({ - ...programOwner, - program: program.approvedId, - })), - enrollments: trackedEntity.enrollments.map(enrollment => ({ - ...enrollment, - enrollment: "", - trackedEntity: "", - program: program.approvedId, - events: enrollment.events.map(event => { - const approvedProgramStage = - programStages.find(programStage => event.programStage === programStage.id) - ?.approvedId ?? ""; - - return { - ...event, - event: "", - trackedEntity: "", - program: program.approvedId, - programStage: approvedProgramStage, - }; - }), - })), - })); - - await this.importTrackedEntitiesByStrategy(trackedEntitiesToPost, "CREATE_AND_UPDATE", true); - } - }); - } - - private async getProgramStages(program: string): Promise { - const { programs } = await this.api.metadata - .get({ - programs: { - fields: { - programStages: { - id: true, - name: true, - }, - }, - filter: { - id: { eq: program }, - }, - paging: false, - }, - }) - .getData(); - - return _.first(programs)?.programStages ?? []; - } - - private async duplicateEventProgram( - program: ApprovalIds, - items: GLASSDataSubmissionItemIdentifier[], - isEGASPModule?: boolean - ): Promise { - _.forEach(items, async item => { - const programEvents = await this.getTrackerProgramEvents(program.id, item.orgUnit, isEGASPModule ?? false); - const events = programEvents.filter( - event => String(new Date(event.occurredAt).getFullYear()) === item.period - ); - - if (!_.isEmpty(events)) { - const approvedProgramEvents = await this.getTrackerProgramEvents( - program.approvedId, - item.orgUnit, - isEGASPModule ?? false - ); - const approvedEvents = approvedProgramEvents.filter( - event => String(new Date(event.occurredAt).getFullYear()) === item.period - ); - - if (!_.isEmpty(approvedEvents)) { - await this.importEventsByStrategy(approvedEvents, "DELETE"); - } - - const approvedProgramStage = (await this.getProgramStages(program.approvedId))[0]; - const eventsToPost = events.map(event => { - return { - ...event, - event: "", - program: program.approvedId, - programStage: approvedProgramStage?.id ?? "", - }; - }); - - await this.createTrackerProgramEventsByChunks(eventsToPost); - } - }); - } - - async reject( - namespace: string, - items: GLASSDataSubmissionItemIdentifier[], - message: string, - isDatasetUpdate: boolean, - signals?: EARSubmissionItemIdentifier[] - ) { - const modules = await this.getModules(); - - if (!_.isEmpty(items)) { - const objects = await this.globalStorageClient.listObjectsInCollection(namespace); - const newSubmissionValues = this.getNewSubmissionValues( - items, - objects, - isDatasetUpdate ? "APPROVED" : "REJECTED" - ); - const recipients = await this.getRecipientUsers(items, modules); - - const body = `Please review the messages and the reports to find about the causes of this rejection. You have to upload new datasets.\n Reason for rejection:\n ${message}`; - - this.sendNotifications("Rejected by WHO", body, [], recipients); - - await this.postDataSetRegistration(items, modules, false); - - return await this.globalStorageClient.saveObject(namespace, newSubmissionValues); - } else if (signals) { - const objects = await this.globalStorageClient.listObjectsInCollection(namespace); - const newSubmissionValues = this.getNewEARSubmissionValues(signals, objects, "REJECTED"); - const recipients = await this.getEARRecipientUsers(signals, modules); - - const notificationText = this.getEARNotificationText(signals, modules, "rejected"); - const body = `${notificationText} with the following message:\n${message}`; - - this.sendNotifications(notificationText, body, [], recipients); - - return await this.globalStorageClient.saveObject(namespace, newSubmissionValues); - } - } - - async reopen(namespace: string, items: GLASSDataSubmissionItemIdentifier[]) { - const objects = await this.globalStorageClient.listObjectsInCollection(namespace); - const modules = await this.getModules(); - - const newSubmissionValues = this.getNewSubmissionValues(items, objects, "NOT_COMPLETED"); - const recipients = await this.getRecipientUsers(items, modules); - - const message = await this.getNotificationText(items, modules, "reopened"); - - this.sendNotifications(message, message, [], recipients); - - await this.postDataSetRegistration(items, modules, false); - - return await this.globalStorageClient.saveObject(namespace, newSubmissionValues); - } - - async accept(namespace: string, items: GLASSDataSubmissionItemIdentifier[]) { - const objects = await this.globalStorageClient.listObjectsInCollection(namespace); - const modules = await this.getModules(); - - const newSubmissionValues = this.getNewSubmissionValues(items, objects, "UPDATE_REQUEST_ACCEPTED"); - const recipients = await this.getRecipientUsers(items, modules); - - const message = await this.getNotificationText(items, modules, "update request accepted"); - this.sendNotifications(message, message, [], recipients); - - await this.postDataSetRegistration(items, modules, false); - - return await this.globalStorageClient.saveObject(namespace, newSubmissionValues); - } - - async getGLASSDashboardId(): Promise { - const modules = await this.getModules(); - const glassUnapvdDashboardId = modules.find(module => module.name === "AMR")?.dashboards.validationReport ?? ""; - - return glassUnapvdDashboardId; - } - - async getOrganisationUnitsWithChildren(): Promise { - const { organisationUnits } = await this.api.metadata - .get({ - organisationUnits: { - fields: { - id: true, - name: true, - level: true, - path: true, - children: { - id: true, - name: true, - level: true, - path: true, - children: { id: true, name: true, level: true, path: true }, - }, - }, - filter: { level: { eq: "1" } }, - }, - }) - .getData(); - - const regionalLevelOUs = organisationUnits.flatMap(ou => ou.children); - const countryLevelOUs = regionalLevelOUs.flatMap(ou => ou.children); - const allOrgUnits = _.union(organisationUnits, regionalLevelOUs, countryLevelOUs); - - return _.orderBy(allOrgUnits, "level", "asc"); - } - - private async postDataSetRegistration( - items: GLASSDataSubmissionItemIdentifier[], - modules: GLASSDataSubmissionModule[], - completed: boolean - ) { - const selectedModule = items[0]?.module; - const moduleQuestionnaires = - modules - .find(module => module.id === selectedModule) - ?.questionnaires.map(questionnaire => questionnaire.id) ?? []; - - const dataSetRegistrations = items.flatMap(item => - moduleQuestionnaires.map(dataSet => ({ - dataSet: dataSet, - period: item.period, - organisationUnit: item.orgUnit, - completed, - })) - ); - - if (!_(moduleQuestionnaires).compact().isEmpty()) { - await this.api - .post( - "/completeDataSetRegistrations", - {}, - { completeDataSetRegistrations: dataSetRegistrations } - ) - .getData(); - } - } - - private async sendNotifications(subject: string, text: string, userGroups: Ref[], users?: Ref[]): Promise { - this.api.messageConversations.post({ - subject, - text, - userGroups, - users, - }); - } - - private getNewSubmissionValues( - items: GLASSDataSubmissionItemIdentifier[], - objects: GLASSDataSubmissionItem[], - status: Status - ) { - return objects.map(object => { - const isNewItem = !!items.find( - item => - item.orgUnit === object.orgUnit && - item.module === object.module && - item.period === String(object.period) - ); - - const statusHistory = { - changedAt: new Date().toISOString(), - from: object.status, - to: status, - }; - - return isNewItem ? { ...object, status, statusHistory: [...object.statusHistory, statusHistory] } : object; - }); - } - - private getNewEARSubmissionValues( - signals: EARSubmissionItemIdentifier[], - objects: EARDataSubmissionItem[], - status: Status - ) { - return objects.map(object => { - const isNewItem = !!signals.find( - signal => - signal.orgUnitId === object.orgUnit.id && signal.module === object.module && signal.id === object.id - ); - - const statusHistory = { - changedAt: new Date().toISOString(), - from: object.status, - to: status, - }; - - return isNewItem - ? { ...object, status: status, statusHistory: [...object.statusHistory, statusHistory] } - : object; - }); - } -} - -const emptyPage: PaginatedObjects = { - pager: { page: 1, pageCount: 1, pageSize: 10, total: 1 }, - objects: [], -}; - -const AMC_PRODUCT_REGISTER_CODE = "AMR_GLASS_AMC_PRO_PRODUCT_REGISTER"; - -const moduleMapping: Record = { - AMC: "AMC", - AMR: "AMR", - AMR_FUNGHI: "AMR - Fungal", // to do: remove this line when submissions have value AMR_FUNGAL - AMR_FUNGAL: "AMR - Fungal", - AMR_INDIVIDUAL: "AMR - Individual", - EAR: "EAR", - EGASP: "EGASP", -}; - -type RegistrationItemBase = Pick< - GLASSDataSubmissionItem, - "orgUnitName" | "orgUnit" | "period" | "questionnaireCompleted" ->; - -function getDatasetsUploaded( - uploads: GLASSDataSubmissionItemUpload[], - object: GLASSDataSubmissionItem | undefined -): string { - const uploadStatus = uploads.filter(upload => upload.dataSubmission === object?.id).map(item => item.status); - const completedDatasets = uploadStatus.filter(item => item === "COMPLETED").length; - const validatedDatasets = uploadStatus.filter(item => item === "VALIDATED").length; - const importedDatasets = uploadStatus.filter(item => item === "IMPORTED").length; - const uploadedDatasets = uploadStatus.filter(item => item === "UPLOADED").length; - - let dataSetsUploaded = ""; - if (completedDatasets > 0) { - dataSetsUploaded += `${completedDatasets} completed, `; - } - if (validatedDatasets > 0) { - dataSetsUploaded += `${validatedDatasets} validated, `; - } - if (importedDatasets > 0) { - dataSetsUploaded += `${importedDatasets} imported, `; - } - if (uploadedDatasets > 0) { - dataSetsUploaded += `${uploadedDatasets} uploaded, `; - } - - // Remove trailing comma and space if any - dataSetsUploaded = dataSetsUploaded.replace(/,\s*$/, ""); - - // Show "No datasets" if all variables are 0 - if (dataSetsUploaded === "" || !dataSetsUploaded) { - dataSetsUploaded = "No datasets"; - } - return dataSetsUploaded; -} - -function getRegistrationKey(options: { orgUnitId: Id; period: string }): RegistrationKey { - return [options.orgUnitId, options.period].join("."); -} - -type RegistrationKey = string; // `${orgUnitId}.${period}` - -const flattenNodes = (orgUnitNodes: OrgUnitNode[]): OrgUnitNode[] => - _.flatMap(orgUnitNodes, node => (node.children ? [node, ...flattenNodes(node.children)] : [node])); - -type OrgUnitNode = { - level: number; - id: string; - children?: OrgUnitNode[]; -}; - -const eventFields = { - program: true, - programStage: true, - event: true, - dataValues: true, - orgUnit: true, - occurredAt: true, - status: true, -} as const; - -type D2TrackerEvent = SelectedPick; - -const trackedEntitiesFields = { - trackedEntity: true, - trackedEntityType: true, - orgUnit: true, - enrollments: { - trackedEntity: true, - occurredAt: true, - orgUnit: true, - program: true, - enrollment: true, - status: true, - enrolledAt: true, - events: { - orgUnit: true, - program: true, - programStage: true, - event: true, - occurredAt: true, - dataValues: { - dataElement: true, - value: true, - }, - }, - }, - attributes: { - attribute: true, - valueType: true, - value: true, - }, - programOwners: true, -} as const; - -type D2TrackerEntity = SelectedPick; diff --git a/src/data/reports/mal-data-approval/MalDataApprovalDefaultRepository.ts b/src/data/reports/mal-data-approval/MalDataApprovalDefaultRepository.ts index a015089..005cb6a 100644 --- a/src/data/reports/mal-data-approval/MalDataApprovalDefaultRepository.ts +++ b/src/data/reports/mal-data-approval/MalDataApprovalDefaultRepository.ts @@ -32,9 +32,10 @@ import { MalDataSet } from "./constants/MalDataApprovalConstants"; import { getMetadataByIdentifiableToken } from "../../common/utils/getMetadataByIdentifiableToken"; import { Maybe } from "../../../types/utils"; import { DataValueStats } from "../../../domain/common/entities/DataValueStats"; -import { approvalReportSettings } from "../../ApprovalReportData"; import { DATA_ELEMENT_SUFFIX } from "../../../domain/common/entities/AppSettings"; import { Log } from "../../../domain/reports/mal-data-approval/usecases/UpdateMalApprovalStatusUseCase"; +import { DataSetWithConfigPermissions } from "../../../domain/usecases/GetApprovalConfigurationsUseCase"; +import { NamedRef } from "../../../domain/common/entities/Ref"; interface VariableHeaders { dataSets: string; @@ -153,6 +154,7 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito sorting, useOldPeriods, modificationCount, + dataSetsConfig, } = options; if (!dataSetId) return emptyPage; const dataSetResponse = await this.api.models.dataSets @@ -164,7 +166,7 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito const sqlViews = new Dhis2SqlViews(this.api); const pagingToDownload = { page: 1, pageSize: 10000 }; - const dataSetSettings = config.appSettings.dataSets[dataSet.code]; + const dataSetSettings = dataSetsConfig.find(ds => ds.configuration.dataSetOriginalCode === dataSet.code); if (!dataSetSettings) throw new Error(`Data set settings not found for ID: ${dataSetId}`); const sqlVariables = { @@ -181,7 +183,7 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito const headerRows = await this.getSqlViewHeaders(sqlViews, options, pagingToDownload); const rows = await this.getSqlViewRows( sqlViews, - useOldPeriods ? dataSetSettings.oldDataSourceId : dataSetSettings.dataSourceId, + useOldPeriods ? dataSetSettings.configuration.oldDataSourceId : dataSetSettings.configuration.dataSourceId, sqlVariables, pagingToDownload ); @@ -295,20 +297,23 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito } } - async approve(dataSets: MalDataApprovalItemIdentifier[], log?: Log): Promise { + async approve(options: { + dataSets: MalDataApprovalItemIdentifier[]; + log?: Log; + dataSetConfig: DataSetWithConfigPermissions; + }): Promise { + const { dataSets, log, dataSetConfig } = options; try { + const DEFAULT_COC = (await this.getDefaultCombination()).id; const originalDataSetId = dataSets[0]?.dataSet; if (!originalDataSetId) throw Error("No data set ID found"); - const { dataSetId } = await this.getApprovalDataSetId([{ dataSet: originalDataSetId }]); - const settings = await this.getSettingByDataSet([dataSetId]); - const dataSetSettings = settings.find(setting => setting.dataSetId === dataSetId); - if (!dataSetSettings) throw new Error(`Data set settings not found: ${dataSetId}`); + const submissionDECode = dataSetConfig.configuration.submissionDateCode; const dataElement = await getMetadataByIdentifiableToken({ api: this.api, metadataType: "dataElements", - token: dataSetSettings.dataElements.submissionDate, + token: submissionDECode, }); const currentDate = getISODate(); @@ -355,7 +360,7 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito const shouldCompleteDataSet = dataSetsToComplete.length !== 0; - if (shouldCompleteDataSet) { + if (shouldCompleteDataSet && dataSetConfig.configuration.submitAndComplete) { this.logMessage(log, i18n.t("Completing dataset...")); } @@ -382,44 +387,25 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito if (log && message) log(message); } - async getApprovalDataSetId(dataApprovalItems: { dataSet: Id }[]) { - const dataSetId = dataApprovalItems[0]?.dataSet; - if (!dataSetId) throw new Error("Data set not found"); - - const { name: dataSetName } = await getMetadataByIdentifiableToken({ - api: this.api, - metadataType: "dataSets", - token: dataSetId, - }); - - const settings = await this.getSettingByDataSet([dataSetId]); - const dataSetSettings = settings.find(setting => setting.dataSetId === dataSetId); - - const approvedDataSetCode = dataSetSettings?.approvalDataSetCode; - if (!approvedDataSetCode) throw new Error(`Approved data set code not found for data set: ${dataSetName}`); - - const { id: apvdDataSetId } = await getMetadataByIdentifiableToken({ - api: this.api, - metadataType: "dataSets", - token: approvedDataSetCode, - }); - - return { approvalDataSetId: apvdDataSetId, dataSetId }; - } - async duplicateDataSets( dataSets: MalDataApprovalItemIdentifier[], - dataElementsWithValues: DataDiffItemIdentifier[] + dataElementsWithValues: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions ): Promise { try { - const { approvalDataSetId, dataSetId } = await this.getApprovalDataSetId(dataSets); + const DEFAULT_COC = (await this.getDefaultCombination()).id; + const approvalDataSet = await getMetadataByIdentifiableToken({ + api: this.api, + metadataType: "dataSets", + token: dataSetConfig.configuration.dataSetDestinationCode, + }); const dataValueSets: DataSetsValueType[] = await this.getDataValueSets(dataSets); const uniqueDataSets = _.uniqBy(dataSets, "dataSet"); const DSDataElements = await this.getDSDataElements(uniqueDataSets); - const ADSDataElements: DataElementsType[] = await this.getADSDataElements(approvalDataSetId); + const ADSDataElements: DataElementsType[] = await this.getADSDataElements(approvalDataSet.id); const dataElementsMatchedArray = DSDataElements.flatMap(DSDataElement => { return DSDataElement.dataSetElements.map(element => { @@ -433,11 +419,17 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito }); }); - const dataValues = this.makeDataValuesArray(approvalDataSetId, dataValueSets, dataElementsMatchedArray); + const dataValues = this.makeDataValuesArray(approvalDataSet.id, dataValueSets, dataElementsMatchedArray); - await this.addTimestampsToDataValuesArray(approvalDataSetId, dataSets, dataValues, dataSetId); + await this.addTimestampsToDataValuesArray( + approvalDataSet.id, + dataSets, + dataValues, + dataSetConfig.configuration.approvalDateCode, + DEFAULT_COC + ); - await this.deleteEmptyDataValues(approvalDataSetId, ADSDataElements, dataElementsWithValues); + await this.deleteEmptyDataValues(approvalDataSet.id, ADSDataElements, dataElementsWithValues); return this.chunkedDataValuePost(dataValues, 3000); } catch (error: any) { console.debug(error); @@ -445,9 +437,18 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito } } - async duplicateDataValues(dataValues: DataDiffItemIdentifier[]): Promise { + async duplicateDataValues( + dataValues: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions + ): Promise { try { - const { approvalDataSetId, dataSetId } = await this.getApprovalDataSetId(dataValues); + const DEFAULT_COC = (await this.getDefaultCombination()).id; + const approvalDataSet = await getMetadataByIdentifiableToken({ + api: this.api, + metadataType: "dataSets", + token: dataSetConfig.configuration.dataSetDestinationCode, + }); + const uniqueDataSets = _.uniqBy(dataValues, "dataSet"); const uniqueDataElementsNames = _.uniq(_.map(dataValues, x => x.dataElement)); @@ -455,7 +456,7 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito const dataValueSets = await this.getDataValueSets(uniqueDataSets); - const ADSDataElements = await this.getADSDataElements(approvalDataSetId); + const ADSDataElements = await this.getADSDataElements(approvalDataSet.id); const dataElementsMatchedArray = DSDataElements.flatMap(DSDataElement => { return DSDataElement.dataSetElements.flatMap(element => { @@ -474,11 +475,21 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito }); }); - const apvdDataValues = this.makeDataValuesArray(approvalDataSetId, dataValueSets, dataElementsMatchedArray); + const apvdDataValues = this.makeDataValuesArray( + approvalDataSet.id, + dataValueSets, + dataElementsMatchedArray + ); - await this.addTimestampsToDataValuesArray(approvalDataSetId, dataValues, apvdDataValues, dataSetId); + await this.addTimestampsToDataValuesArray( + approvalDataSet.id, + dataValues, + apvdDataValues, + dataSetConfig.configuration.approvalDateCode, + DEFAULT_COC + ); - await this.deleteEmptyDataValues(approvalDataSetId, ADSDataElements, dataValues); + await this.deleteEmptyDataValues(approvalDataSet.id, ADSDataElements, dataValues); return this.chunkedDataValuePost(apvdDataValues, 3000); } catch (error: any) { @@ -488,11 +499,18 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito } // TODO: All this logic must be in the domain. ApproveMalDataValuesUseCase.ts - async replicateDataValuesInApvdDataSet(originalDataValues: DataDiffItemIdentifier[]): Promise { - const { approvalDataSetId, dataSetId } = await this.getApprovalDataSetId(originalDataValues); - const settings = await this.getSettingByDataSet([dataSetId]); - const dataSetSettings = settings.find(setting => setting.dataSetId === dataSetId); - if (!dataSetSettings) throw new Error(`Data set settings not found: ${dataSetId}`); + async replicateDataValuesInApvdDataSet(options: { + originalDataValues: DataDiffItemIdentifier[]; + dataSetConfig: DataSetWithConfigPermissions; + }): Promise { + const { originalDataValues, dataSetConfig } = options; + const DEFAULT_COC = (await this.getDefaultCombination()).id; + const dataSetApproval = await getMetadataByIdentifiableToken({ + api: this.api, + metadataType: "dataSets", + token: dataSetConfig.configuration.dataSetDestinationCode, + }); + const approvalDataSetId = dataSetApproval.id; const approvalDataElements = await this.getADSDataElements(approvalDataSetId); const approvalDeByName = _.keyBy(approvalDataElements, dataElement => dataElement.name.toLowerCase()); @@ -500,7 +518,7 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito const approvalDateDataElement = await getMetadataByIdentifiableToken({ api: this.api, metadataType: "dataElements", - token: dataSetSettings.dataElements.approvalDate, + token: dataSetConfig.configuration.approvalDateCode, }); const approvalDataValues = _(originalDataValues) @@ -536,7 +554,8 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito const timeStampDataValues = this.generateTimeStampDataValue( approvalDataValues, approvalDataSetId, - approvalDateDataElement.id + approvalDateDataElement.id, + DEFAULT_COC ); const deleteStats = await this.deleteEmptyDataValues( @@ -641,7 +660,8 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito private generateTimeStampDataValue( dataValues: D2DataValue[], approvalDataSetId: Id, - dataElementId: Id + dataElementId: Id, + DEFAULT_COC: string ): D2DataValue[] { const dataValuesByOrgUnitAndPeriod = _(dataValues) .groupBy(item => `${item.orgUnit}-${item.period}`) @@ -666,16 +686,6 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito }); } - private async getApprovalDataSetIdentifier(): Promise { - const { id } = await getMetadataByIdentifiableToken({ - api: this.api, - metadataType: "dataSets", - token: MAL_WMR_FORM_APVD_NAME, - }); - - return process.env.REACT_APP_APPROVE_DATASET_ID ?? id; - } - private async deleteEmptyDataValues( approvalDataSetId: string, approvedDataElements: DataElementsType[], @@ -751,15 +761,13 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito approvalDataSetId: string, actionItems: MalDataApprovalItemIdentifier[] | DataDiffItemIdentifier[], dataValues: DataValueType[], - originalDataSetId: string + approvalDateCode: string, + DEFAULT_COC: string ) { - const settings = await this.getSettingByDataSet([originalDataSetId]); - const dataSetSettings = settings.find(setting => setting.dataSetId === originalDataSetId); - if (!dataSetSettings) throw new Error(`Data set settings not found: ${originalDataSetId}`); const malApprovalDateDataElement = await getMetadataByIdentifiableToken({ api: this.api, metadataType: "dataElements", - token: dataSetSettings.dataElements.approvalDate, + token: approvalDateCode, }); actionItems.forEach(actionItem => { @@ -839,9 +847,12 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito } } - async duplicateDataValuesAndRevoke(dataValues: DataDiffItemIdentifier[]): Promise { + async duplicateDataValuesAndRevoke( + dataValues: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions + ): Promise { try { - const duplicateResponse = await this.duplicateDataValues(dataValues); + const duplicateResponse = await this.duplicateDataValues(dataValues, dataSetConfig); const revokeData: DataDiffItemIdentifier = { dataSet: dataValues[0]?.dataSet ?? "", @@ -990,19 +1001,19 @@ export class MalDataApprovalDefaultRepository implements MalDataApprovalReposito } } - private async getSettingByDataSet(dataSetIds: Id[]) { - const response = await this.api.models.dataSets + async getDefaultCombination(): Promise { + const response = await this.api.models.categoryOptionCombos .get({ - fields: { id: true, code: true }, - filter: { id: { in: dataSetIds } }, + fields: { id: true, name: true }, + paging: false, + filters: { name: { eq: "default" } }, }) .getData(); - return response.objects.map(dataSet => { - const settings = approvalReportSettings.dataSets[dataSet.code]; - if (!settings) throw new Error(`No settings found for dataSet: ${dataSet.code}`); - return { ...settings, dataSetId: dataSet.id }; - }); + const defaultCombo = response.objects[0]; + if (!defaultCombo) throw new Error("Default category option combo not found"); + + return { id: defaultCombo.id, name: defaultCombo.name }; } } @@ -1029,7 +1040,7 @@ function mergeHeadersAndData( headers: SqlViewGetData["rows"], data: SqlViewGetData["rows"] ) { - const { sorting, paging, orgUnitIds, periods, approvalStatus, completionStatus } = options; // ? + const { sorting, paging, orgUnitIds, periods, approvalStatus, completionStatus } = options; const rows: Array = []; const mapping = _(data) @@ -1091,8 +1102,6 @@ function mergeHeadersAndData( } export const MAL_WMR_FORM_CODE = "0MAL_5"; -const MAL_WMR_FORM_APVD_NAME = "MAL - WMR Form-APVD"; -const DEFAULT_COC = "Xr12mI7VPn3"; type D2DataValue = DataValueSetsPostRequest["dataValues"][number]; diff --git a/src/data/reports/mal-data-approval/UserGroupD2Repository.ts b/src/data/reports/mal-data-approval/UserGroupD2Repository.ts index db84bc5..cb5d2c0 100644 --- a/src/data/reports/mal-data-approval/UserGroupD2Repository.ts +++ b/src/data/reports/mal-data-approval/UserGroupD2Repository.ts @@ -1,15 +1,43 @@ import { D2Api } from "@eyeseetea/d2-api/2.34"; +import { Future, FutureData } from "../../../domain/generic/Future"; import { UserGroup, UserGroupRepository, } from "../../../domain/reports/mal-data-approval/repositories/UserGroupRepository"; +import _ from "../../../domain/generic/Collection"; +import { apiToFuture } from "../../api-futures"; export class UserGroupD2Repository implements UserGroupRepository { constructor(private api: D2Api) {} + getByCodes(codes: string[]): FutureData { + if (codes.length === 0) return Future.success([]); + + const $requests = _(codes) + .chunk(100) + .map(groupCodes => { + return apiToFuture( + this.api.models.userGroups.get({ + fields: { id: true, displayName: true, code: true }, + filter: { code: { in: groupCodes } }, + paging: false, + }) + ); + }) + .value(); + + return Future.parallel($requests, { concurrency: 3 }).map(allResponses => { + return allResponses.flat().flatMap(response => { + return response.objects.map(d2UserGroup => { + return { id: d2UserGroup.id, name: d2UserGroup.displayName, code: d2UserGroup.code }; + }); + }); + }); + } + async getUserGroupByCode(code: string): Promise { const { objects: userGroups } = await this.api.models.userGroups - .get({ fields: { id: true }, filter: { code: { eq: code } } }) + .get({ fields: { id: true, displayName: true, code: true }, filter: { code: { eq: code } } }) .getData(); const userGroup = userGroups[0]; @@ -17,6 +45,6 @@ export class UserGroupD2Repository implements UserGroupRepository { throw new Error(`User group with code ${code} not found`); } - return userGroup; + return { id: userGroup.id, name: userGroup.displayName, code: userGroup.code }; } } diff --git a/src/domain/common/entities/Config.ts b/src/domain/common/entities/Config.ts index a0f5ea4..f6742b3 100644 --- a/src/domain/common/entities/Config.ts +++ b/src/domain/common/entities/Config.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import { AppSettings } from "./AppSettings"; import { Id, NamedRef } from "./Base"; import { getPath } from "./OrgUnit"; import { User } from "./User"; @@ -20,7 +19,6 @@ export interface Config { | undefined; years: string[]; approvalWorkflow: NamedRef[]; - appSettings: AppSettings; timeZoneId: string; } diff --git a/src/domain/common/entities/DataSet.ts b/src/domain/common/entities/DataSet.ts index 5d057d7..954a93c 100644 --- a/src/domain/common/entities/DataSet.ts +++ b/src/domain/common/entities/DataSet.ts @@ -1,3 +1,4 @@ +import { Maybe } from "../../../types/utils"; import { Code, Id } from "./Base"; import { OrgUnit } from "./OrgUnit"; @@ -7,6 +8,7 @@ export type DataSet = { name: string; organisationUnits: OrgUnit[]; dataElements: DataElement[]; + periodType: Maybe; }; export type DataElement = { @@ -29,3 +31,10 @@ export type CategoryOptionCombo = { name: string; code: string; }; + +const periodTypes = ["Monthly", "Yearly"] as const; +export type PeriodType = typeof periodTypes[number]; + +export const getAllowedPeriodType = (value: string): Maybe => { + return periodTypes.find(pt => pt === value); +}; diff --git a/src/domain/common/entities/DataSetStatus.ts b/src/domain/common/entities/DataSetStatus.ts index 92dc781..908898c 100644 --- a/src/domain/common/entities/DataSetStatus.ts +++ b/src/domain/common/entities/DataSetStatus.ts @@ -1,4 +1,4 @@ -import { Struct } from "./Struct"; +import { Struct } from "../../generic/Struct"; type DataSetStatusAttrs = { isSubmitted: boolean; diff --git a/src/domain/common/entities/User.ts b/src/domain/common/entities/User.ts index bec86b3..67027fb 100644 --- a/src/domain/common/entities/User.ts +++ b/src/domain/common/entities/User.ts @@ -7,19 +7,6 @@ export interface User { username: string; orgUnits: OrgUnit[]; userRoles: NamedRef[]; - userGroups: NamedRef[]; + userGroups: Array; isAdmin: boolean; - dataSets?: Record; } - -export type UserDataSetAccess = Record; - -export type UserDataSetAction = { - complete: boolean; - incomplete: boolean; - monitoring: boolean; - read: boolean; - revoke: boolean; - submit: boolean; - approve: boolean; -}; diff --git a/src/domain/common/repositories/AppSettingsRepository.ts b/src/domain/common/repositories/AppSettingsRepository.ts deleted file mode 100644 index 0626816..0000000 --- a/src/domain/common/repositories/AppSettingsRepository.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AppSettings } from "../entities/AppSettings"; - -export interface AppSettingsRepository { - get(): Promise; -} diff --git a/src/domain/common/repositories/DataSetRepository.ts b/src/domain/common/repositories/DataSetRepository.ts index 23fd9f1..f89ead7 100644 --- a/src/domain/common/repositories/DataSetRepository.ts +++ b/src/domain/common/repositories/DataSetRepository.ts @@ -1,7 +1,9 @@ +import { FutureData } from "../../generic/Future"; import { Id } from "../entities/Base"; import { DataSet } from "../entities/DataSet"; export interface DataSetRepository { getById(id: Id): Promise; getByNameOrCode(nameOrCode: string): Promise; + getByCodes(codes: string[]): FutureData; } diff --git a/src/domain/entities/DataSetConfiguration.ts b/src/domain/entities/DataSetConfiguration.ts new file mode 100644 index 0000000..d1bbbfe --- /dev/null +++ b/src/domain/entities/DataSetConfiguration.ts @@ -0,0 +1,155 @@ +import { Struct } from "../generic/Struct"; +import { Code, Id } from "../common/entities/Base"; +import { generateUid } from "../../utils/uid"; +import { ValidationError } from "../generic/Errors"; +import { Maybe } from "../../types/utils"; +import _ from "../generic/Collection"; + +export type DataSetConfigurationPermissions = { + userGroups: string[]; + users: string[]; +}; + +export type DataSetConfigurationDataElements = { + submissionDateCode: string; + approvalDateCode: string; +}; + +export type DataSetConfigurationAttrs = { + id: string; + dataSetOriginalCode: string; + dataSetDestinationCode: string; + submissionDateCode: string; + approvalDateCode: string; + permissions: Record; + dataSourceId: Id; + oldDataSourceId: Id; + submitAndComplete: boolean; + revokeAndIncomplete: boolean; +}; + +export class DataSetConfiguration extends Struct() { + static CODE_PREFIX = "DS_"; + static EMPTY_PERMISSIONS: DataSetConfigurationPermissions = { users: [], userGroups: [] }; + + get code(): string { + return `${DataSetConfiguration.CODE_PREFIX}${this.dataSetOriginalCode}_${this.dataSetDestinationCode}`; + } + + static initial(): DataSetConfiguration { + return new DataSetConfiguration({ + id: generateUid(), + dataSetOriginalCode: "", + dataSetDestinationCode: "", + submissionDateCode: "", + approvalDateCode: "", + permissions: this.getEmptyPermissions(), + dataSourceId: "", + oldDataSourceId: "", + submitAndComplete: false, + revokeAndIncomplete: false, + }); + } + + updatePermissions(options: { action: DataSetConfigurationAction; usernames: string[]; userGroupCodes: string[] }) { + const { action, usernames, userGroupCodes } = options; + return this._update({ + permissions: { ...this.permissions, [action]: { users: usernames, userGroups: userGroupCodes } }, + }); + } + + updateDataSetOriginal(newDataSetCode: string): DataSetConfiguration { + return this._update({ dataSetOriginalCode: newDataSetCode }); + } + + updateDataSetDestination(newDataSetCode: string): DataSetConfiguration { + return this._update({ dataSetDestinationCode: newDataSetCode }); + } + + updateSubmissionDateDataElement(newDataElementCode: string): DataSetConfiguration { + return this._update({ submissionDateCode: newDataElementCode }); + } + + updateApprovalDateDataElement(newDataElementCode: string): DataSetConfiguration { + return this._update({ approvalDateCode: newDataElementCode }); + } + + updateDataSourceId(id: Id): DataSetConfiguration { + return this._update({ dataSourceId: id }); + } + + updateOldDataSourceId(id: Id): DataSetConfiguration { + return this._update({ oldDataSourceId: id }); + } + + updateSubmitAndComplete(value: boolean): DataSetConfiguration { + return this._update({ submitAndComplete: value }); + } + + updateRevokeAndIncomplete(value: boolean): DataSetConfiguration { + return this._update({ revokeAndIncomplete: value }); + } + + hasPermission(action: DataSetConfigurationAction, username: string, userGroupCodes: Code[]): boolean { + const actionPermissions = this.permissions[action]; + + // Check if user has direct permission + if (actionPermissions.users.includes(username)) { + return true; + } + + // Check if user belongs to any group with permission + return userGroupCodes.some(groupCode => actionPermissions.userGroups.includes(groupCode)); + } + + canUserPerformAction( + action: DataSetConfigurationAction, + username: string, + userGroupCodes: Code[], + isSuperAdmin: boolean + ): boolean { + return isSuperAdmin ? true : this.hasPermission(action, username, userGroupCodes); + } + + public static validate(data: DataSetConfigurationAttrs): DataSetConfigurationError[] { + const validationResults = _([ + this.validateRequired("dataSetOriginalCode", data.dataSetOriginalCode), + this.validateRequired("dataSetDestinationCode", data.dataSetDestinationCode), + this.validateRequired("submissionDateCode", data.submissionDateCode), + this.validateRequired("approvalDateCode", data.approvalDateCode), + this.validateRequired("dataSourceId", data.dataSourceId), + this.validateRequired("oldDataSourceId", data.oldDataSourceId), + ]) + .compact() + .value(); + + return validationResults.flatMap(result => (result !== undefined ? [result] : [])); + } + + dataSetsAreEqual(): boolean { + return this.dataSetOriginalCode === this.dataSetDestinationCode; + } + + private static validateRequired( + property: K, + value: DataSetConfigurationAttrs[K] + ): Maybe { + return value ? undefined : { property, value, errors: ["field_cannot_be_blank"] }; + } + + private static getEmptyPermissions(): Record { + return { + read: DataSetConfiguration.EMPTY_PERMISSIONS, + complete: DataSetConfiguration.EMPTY_PERMISSIONS, + incomplete: DataSetConfiguration.EMPTY_PERMISSIONS, + revoke: DataSetConfiguration.EMPTY_PERMISSIONS, + submit: DataSetConfiguration.EMPTY_PERMISSIONS, + approve: DataSetConfiguration.EMPTY_PERMISSIONS, + }; + } +} + +export const dataSetConfigurationActions = ["read", "complete", "incomplete", "revoke", "submit", "approve"] as const; + +export type DataSetConfigurationAction = typeof dataSetConfigurationActions[number]; +type DataSetConfigurationError = ValidationError; diff --git a/src/domain/entities/MetadataEntity.ts b/src/domain/entities/MetadataEntity.ts new file mode 100644 index 0000000..d24900e --- /dev/null +++ b/src/domain/entities/MetadataEntity.ts @@ -0,0 +1,10 @@ +import { Id } from "../common/entities/Base"; +import { Struct } from "../generic/Struct"; + +type MetadataEntityAttrs = { + id: Id; + name: string; + code: string; +}; + +export class MetadataEntity extends Struct() {} diff --git a/src/domain/entities/User.ts b/src/domain/entities/User.ts new file mode 100644 index 0000000..fd471fe --- /dev/null +++ b/src/domain/entities/User.ts @@ -0,0 +1,22 @@ +import { Id, NamedRef } from "../common/entities/Base"; +import { Struct } from "../generic/Struct"; + +export type UserAttrs = { + id: Id; + name: string; + username: string; + userRoles: UserRole[]; + userGroups: Array; + isSuperAdmin: boolean; +}; + +export type UserRole = { + id: Id; + name: string; +}; + +export class User extends Struct() { + belongToUserGroup(userGroupUid: string): boolean { + return this.userGroups.some(({ id }) => id === userGroupUid); + } +} diff --git a/src/domain/entities/UserSharing.ts b/src/domain/entities/UserSharing.ts new file mode 100644 index 0000000..abf04a7 --- /dev/null +++ b/src/domain/entities/UserSharing.ts @@ -0,0 +1,6 @@ +import { Id } from "../common/entities/Base"; + +export type UserSharing = { + users: Array<{ id: Id; name: string; username: string }>; + userGroups: Array<{ id: Id; name: string; code: string }>; +}; diff --git a/src/domain/generic/Collection.ts b/src/domain/generic/Collection.ts new file mode 100644 index 0000000..db05850 --- /dev/null +++ b/src/domain/generic/Collection.ts @@ -0,0 +1,286 @@ +/** + * Wrap a collection of values, expanding methods for Javascript Arrays. An example: + * + * ``` + * import _ from "./Collection"; + * + * const values = _(["1", "2", "3", "3", "4"]) + * .map(x => parseInt(x)) + * .filter(x => x > 1) + * .uniq() + * .reverse() + * .value(); // [4, 3, 2] + * ``` + */ + +export class Collection { + protected xs: T[]; + + protected constructor(values: T[]) { + this.xs = values; + } + + /* Builders */ + + static from(xs: T[]): Collection { + return new Collection(xs); + } + + static range(start: number, end: number, step = 1): Collection { + const output = []; + for (let idx = start; idx < end; idx = idx + step) output.push(idx); + return Collection.from(output); + } + + /* Unwrappers */ + + value(): T[] { + return this.xs; + } + + toArray = this.value; + + get size() { + return this.xs.length; + } + + /* Methods that return a Collection */ + + map(fn: (x: T) => U): Collection { + return _c(this.xs.map(fn)); + } + + flatten(): T extends Array ? Collection : never { + return _c(this.xs.flat()) as any; + } + + flatMap(fn: (x: T) => Collection): Collection { + return _c(this.xs.flatMap(x => fn(x).toArray())); + } + + select(pred: (x: T) => boolean): Collection { + return _c(this.xs.filter(pred)); + } + + filter = this.select; + + reject(pred: (x: T) => boolean): Collection { + return _c(this.xs.filter(x => !pred(x))); + } + + enumerate(): Collection<[number, T]> { + return _c(this.xs.map((x, idx) => [idx, x])); + } + + compact(): Collection> { + return this.reject(x => x === undefined || x === null) as unknown as Collection>; + } + + compactMap(fn: (x: T) => U | undefined | null): Collection { + return this.map(fn).compact() as unknown as Collection; + } + + append(x: T): Collection { + return _c(this.xs.concat([x])); + } + + includes(x: T): boolean { + return this.xs.includes(x); + } + + every(pred: (x: T) => boolean): boolean { + return this.xs.every(pred); + } + + all = this.every; + + some(pred: (x: T) => boolean): boolean { + return this.xs.some(pred); + } + + any = this.some; + + find(pred: (x: T) => boolean, options: { or?: Or } = {}): T | Or { + return this.xs.find(pred) || (options?.or as Or); + } + + sort(): Collection { + return this.sortWith(defaultCompareFn); + } + + reverse(): Collection { + return _c([...this.xs].reverse()); + } + + sortWith(compareFn: CompareFn): Collection { + return _c(this.xs.slice().sort(compareFn)); + } + + sortBy(fn: (x: T) => U, options: { compareFn?: CompareFn } = {}): Collection { + const compareFn = options.compareFn || defaultCompareFn; + // TODO: Schwartzian transform: decorate + sort tuple + undecorate + return this.sortWith((a, b) => compareFn(fn(a), fn(b))); + } + + orderBy(items: OrderItem[]): Collection { + return this.sortWith((a, b) => { + return compareArray(a, b, items); + }); + } + + first(): T | undefined { + return this.xs[0]; + } + + last(): T | undefined { + return this.xs[this.xs.length - 1]; + } + + sum(): number { + return this.xs.reduce((acc, x) => acc + Number(x), 0); + } + + take(n: number): Collection { + return _c(this.xs.slice(0, n)); + } + + drop(n: number): Collection { + return _c(this.xs.slice(n)); + } + + pairwise(): Collection<[T, T]> { + const n = 2; + + return _c( + this.xs.slice(0, this.xs.length - n + 1).map((_x, idx) => [this.xs[idx], this.xs[idx + 1]] as [T, T]) + ); + } + + prepend(x: T) { + return _c([x, ...this.xs]); + } + + tap(fn: (xs: Collection) => void) { + fn(this); + return this; + } + + splitAt(indexes: number[]): Collection> { + return _c(indexes) + .prepend(0) + .append(this.xs.length) + .pairwise() + .map(([i1, i2]) => _c(this.xs.slice(i1, i2))); + } + + thru(fn: (xs: Collection) => Collection) { + return fn(this); + } + + join(char: string): string { + return this.xs.join(char); + } + + get(idx: number): T | undefined { + return this.xs[idx]; + } + + getMany(idxs: number[]): Collection { + return _c(idxs.map(idx => this.xs[idx])); + } + + intersperse(value: T): Collection { + return this.flatMap(x => _c([x, value])).thru(cs => cs.take(cs.size - 1)); + } + + uniq(): Collection { + return this.uniqBy(x => x); + } + + uniqBy(mapper: (value: T) => U): Collection { + const seen = new Set(); + const output: Array = []; + + for (const item of this.xs) { + const mapped = mapper(item); + if (!seen.has(mapped)) { + output.push(item); + seen.add(mapped); + } + } + + return _c(output); + } + + reduce(mapper: (acc: U, value: T) => U, initialAcc: U): U { + return this.xs.reduce(mapper, initialAcc); + } + + chunk(size: number): Collection { + return Collection.range(0, this.xs.length, size).map(index => { + return this.xs.slice(index, index + size); + }); + } + + cartesian(): T extends Array ? Collection : never { + const [ys, ...zss] = this.xs; + + if (!ys) { + return _c([[]]) as any; + } else { + return _c(ys as unknown as T[]).flatMap(x => + _c(zss) + .cartesian() + .map(zs => [x, ...zs]) + ) as any; + } + } + + // implement concat method + concat(xs: T[]): Collection { + return _c(this.xs.concat(xs)); + } + + // forEach(fn: (value: T) => void): void + + zipLongest(xs: Collection): Collection<[T | undefined, S | undefined]> { + const max = Math.max(this.size, xs.size); + const pairs = Collection.range(0, max) + .map(i => [this.xs[i], xs.xs[i]] as [T | undefined, S | undefined]) + .value(); + return _c(pairs); + } + + zip(xs: Collection): Collection<[T, S]> { + const min = Math.min(this.size, xs.size); + const pairs = Collection.range(0, min) + .map(i => [this.xs[i], xs.xs[i]] as [T, S]) + .value(); + return _c(pairs); + } +} + +type CompareRes = -1 | 0 | 1; + +type CompareFn = (a: T, b: T) => CompareRes; + +type Direction = "asc" | "desc"; + +function defaultCompareFn(a: T, b: T, direction: Direction = "asc"): CompareRes { + const [value1, value2] = direction === "asc" ? [1 as const, -1 as const] : [-1 as const, 1 as const]; + return a > b ? value1 : b > a ? value2 : 0; +} + +function compareArray(a: T, b: T, items: OrderItem[]): CompareRes { + const item = items[0]; + if (!item) return 0; + const [mapper, direction] = item; + const res = defaultCompareFn(mapper(a), mapper(b), direction); + return res !== 0 ? res : compareArray(a, b, items.slice(1)); +} + +type OrderItem = [(obj: T) => unknown, "asc" | "desc"]; + +export default function _c(xs: T[]): Collection { + return Collection.from(xs); +} diff --git a/src/domain/generic/Errors.ts b/src/domain/generic/Errors.ts new file mode 100644 index 0000000..b7a3bd5 --- /dev/null +++ b/src/domain/generic/Errors.ts @@ -0,0 +1,25 @@ +import i18n from "../../locales"; + +export type ValidationErrorKey = "field_cannot_be_blank" | "same_value_not_allowed"; + +export const validationErrorMessages: Record string> = { + field_cannot_be_blank: (fieldName: string) => + i18n.t(`Cannot be blank: {{fieldName}}`, { fieldName: fieldName, nsSeparator: false }), + same_value_not_allowed: (fieldName: string) => + i18n.t(`The same value is not allowed for: {{fieldName}}`, { fieldName: fieldName, nsSeparator: false }), +}; + +export function getErrors(errors: ValidationError[]) { + return errors + .map(error => { + return error.errors.map(err => validationErrorMessages[err](error.property as string)); + }) + .flat() + .join("\n"); +} + +export type ValidationError = { + property: keyof T; + value: unknown; + errors: ValidationErrorKey[]; +}; diff --git a/src/domain/generic/Future.ts b/src/domain/generic/Future.ts new file mode 100644 index 0000000..5730fa4 --- /dev/null +++ b/src/domain/generic/Future.ts @@ -0,0 +1,248 @@ +import * as rcpromise from "real-cancellable-promise"; + +/** + * Futures are async values similar to promises, with some differences: + * - Futures are only executed when their method `run` is called. + * - Futures are cancellable (thus, they can be easily used in a `React.useEffect`, for example). + * - Futures have fully typed errors. Subclass Error if you need full stack traces. + * - You may still use async/await monad-style blocks (check Future.block). + * + * More info: https://github.com/EyeSeeTea/know-how/wiki/Async-futures + */ +export class Future { + private constructor(private _promise: () => rcpromise.CancellablePromise) {} + + static success(data: D): Future { + return new Future(() => rcpromise.CancellablePromise.resolve(data)); + } + + static error(error: E): Future { + return new Future(() => rcpromise.CancellablePromise.reject(error)); + } + + static fromComputation( + computation: (resolve: (value: D) => void, reject: (error: E) => void) => Cancel + ): Future { + let cancel: Cancel = () => {}; + + return new Future(() => { + const promise = new Promise((resolve, reject) => { + cancel = computation(resolve, error => reject(error)); + }); + + return new rcpromise.CancellablePromise(promise, cancel || (() => {})); + }); + } + + run(onSuccess: (data: D) => void, onError: (error: E) => void): Cancel { + return this._promise().then(onSuccess, err => { + if (err instanceof rcpromise.Cancellation) { + // no-op + } else { + onError(err); + } + }).cancel; + } + + map(fn: (data: D) => U): Future { + return new Future(() => this._promise().then(fn)); + } + + mapError(fn: (error: E) => E2): Future { + return new Future(() => + this._promise().catch((error: E) => { + throw fn(error); + }) + ); + } + + flatMap(fn: (data: D) => Future): Future { + return new Future(() => this._promise().then(data => fn(data)._promise())); + } + + flatMapError(fn: (error: E) => Future): Future { + return new Future(() => { + return this._promise().catch((error: E) => { + return fn(error)._promise(); + }); + }); + } + + chain(fn: (data: D) => Future): Future { + return this.flatMap(fn); + } + + toPromise(): Promise { + return this._promise(); + } + + toVoid(): Future { + return this.map(() => undefined); + } + + static join2(async1: Future, async2: Future): Future { + return new Future(() => { + return rcpromise.CancellablePromise.all([async1._promise(), async2._promise()]); + }); + } + + static joinObj>>( + obj: Obj, + options: ParallelOptions = { concurrency: 1 } + ): Future< + Obj[keyof Obj] extends Future ? E : never, + { [K in keyof Obj]: Obj[K] extends Future ? U : never } + > { + const asyncs = Object.values(obj); + + return Future.parallel(asyncs, options).map(values => { + const keys = Object.keys(obj); + const pairs = keys.map((key, idx) => [key, values[idx]]); + return Object.fromEntries(pairs); + }); + } + + static sequential(asyncs: Future[]): Future { + return Future.block(async $ => { + const output: D[] = []; + for (const async of asyncs) { + const res = await $(async); + output.push(res); + } + return output; + }); + } + + static parallel(asyncs: Future[], options: ParallelOptions): Future { + return new Future(() => + rcpromise.buildCancellablePromise(async $ => { + const queue: rcpromise.CancellablePromise[] = []; + const output: D[] = new Array(asyncs.length); + + for (const [idx, async] of asyncs.entries()) { + const queueItem$ = async._promise().then(res => { + queue.splice(queue.indexOf(queueItem$), 1); + output[idx] = res; + }); + + queue.push(queueItem$); + + if (queue.length >= options.concurrency) await $(rcpromise.CancellablePromise.race(queue)); + } + + await $(rcpromise.CancellablePromise.all(queue)); + return output; + }) + ); + } + + static sleep(ms: number): Future { + return new Future(() => rcpromise.CancellablePromise.delay(ms)).map(() => ms); + } + + static void(): Future { + return Future.success(undefined); + } + + static block(blockFn: (capture: CaptureAsync) => Promise): Future { + return new Future((): rcpromise.CancellablePromise => { + return rcpromise.buildCancellablePromise(capturePromise => { + const captureAsync: CaptureAsync = async => { + return capturePromise(async._promise()); + }; + + captureAsync.throw = function (error: E) { + throw error; + }; + + return blockFn(captureAsync); + }); + }); + } + + static cancel() { + throw new rcpromise.Cancellation(); + } + + static block_() { + return function (blockFn: (capture: CaptureAsync) => Promise): Future { + return Future.block(blockFn); + }; + } + + static sequentialWithAccumulation( + futures: Array>, + options: { stopOnError?: boolean } = {} + ): Future> { + const { stopOnError = false } = options; + const processSequentially = ( + futures: Array>, + accumulatedData: D[] = [] + ): Future> => { + const [firstFuture, ...remainingFutures] = futures; + + if (!firstFuture) { + return Future.success({ type: "success", data: accumulatedData }); + } + + return firstFuture + .flatMap(resultData => { + return processSequentially(remainingFutures, [...accumulatedData, resultData]); + }) + .flatMapError((error: E) => { + if (stopOnError) { + const accumulatedDataWithError: SequentialAccumulatedData = { + type: "error", + error: error, + data: accumulatedData, + }; + return Future.success(accumulatedDataWithError); + } else { + return processSequentially(remainingFutures, accumulatedData); + } + }); + }; + + return processSequentially(futures); + } +} + +export type SequentialAccumulatedData = { type: "success"; data: D[] } | { type: "error"; error: E; data: D[] }; + +export type Cancel = (() => void) | undefined; + +interface CaptureAsync { + (async: Future): Promise; + throw: (error: E) => never; +} + +type ParallelOptions = { concurrency: number }; + +/* Example of how use Future.fromComputation */ +export function getJSON(url: string): Future { + const abortController = new AbortController(); + + return Future.fromComputation((resolve, reject) => { + // exceptions: TypeError | DOMException[name=AbortError] + fetch(url, { method: "get", signal: abortController.signal }) + .then(res => res.json() as unknown as U) // exceptions: SyntaxError + .then(data => resolve(data)) + .catch((error: unknown) => { + if (isNamedError(error) && error.name === "AbortError") { + throw Future.cancel(); + } else if (error instanceof TypeError || error instanceof SyntaxError) { + reject(error); + } else { + reject(new TypeError("Unknown error")); + } + }); + + return () => abortController.abort(); + }); +} + +function isNamedError(error: unknown): error is { name: string } { + return Boolean(error && typeof error === "object" && "name" in error); +} + +export type FutureData = Future; diff --git a/src/domain/common/entities/Struct.ts b/src/domain/generic/Struct.ts similarity index 100% rename from src/domain/common/entities/Struct.ts rename to src/domain/generic/Struct.ts diff --git a/src/domain/reports/WmrDiffReport.ts b/src/domain/reports/WmrDiffReport.ts index fb1461e..c11811a 100644 --- a/src/domain/reports/WmrDiffReport.ts +++ b/src/domain/reports/WmrDiffReport.ts @@ -1,9 +1,10 @@ import _ from "lodash"; -import { AppSettings, isValidApprovalDataElement } from "../common/entities/AppSettings"; +import { isValidApprovalDataElement } from "../common/entities/AppSettings"; import { Id } from "../common/entities/Base"; import { DataValue } from "../common/entities/DataValue"; import { DataSetRepository } from "../common/repositories/DataSetRepository"; import { DataValuesRepository } from "../common/repositories/DataValuesRepository"; +import { DataSetWithConfigPermissions } from "../usecases/GetApprovalConfigurationsUseCase"; import { DataDiffItem } from "./mal-data-approval/entities/DataDiffItem"; export const dataSetApprovalName = "MAL - WMR Form-APVD"; @@ -12,7 +13,7 @@ export class WmrDiffReport { constructor( private dataValueRepository: DataValuesRepository, private dataSetRepository: DataSetRepository, - private settings: AppSettings + private dataSetConfigs: DataSetWithConfigPermissions[] ) {} async getDiff(dataSetId: Id, orgUnitId: Id, period: string, children = false): Promise { @@ -20,10 +21,12 @@ export class WmrDiffReport { const dataSet = await this.dataSetRepository.getById(dataSetId); const originalDataSet = dataSet[0]; if (!originalDataSet) throw Error(`No data set found: ${dataSetId}`); - const settings = this.settings.dataSets[originalDataSet.code]; + const settings = this.dataSetConfigs.find(config => config.dataSet.id === originalDataSet.id); if (!settings) throw Error(`No settings found for data set: ${originalDataSet.code}`); - const dataSetApproval = await this.dataSetRepository.getByNameOrCode(settings.approvalDataSetCode); + const dataSetApproval = await this.dataSetRepository.getByNameOrCode( + settings.configuration.dataSetDestinationCode + ); const approvalDataValues = await this.getDataValues(dataSetApproval.id, orgUnitId, period, children); const malDataValues = await this.getDataValues(dataSetId, orgUnitId, period, children); diff --git a/src/domain/reports/mal-data-approval/repositories/MalDataApprovalRepository.ts b/src/domain/reports/mal-data-approval/repositories/MalDataApprovalRepository.ts index 237caea..3ba857d 100644 --- a/src/domain/reports/mal-data-approval/repositories/MalDataApprovalRepository.ts +++ b/src/domain/reports/mal-data-approval/repositories/MalDataApprovalRepository.ts @@ -5,19 +5,34 @@ import { MalDataApprovalItem, MalDataApprovalItemIdentifier } from "../entities/ import { DataDiffItemIdentifier } from "../entities/DataDiffItem"; import { DataValueStats } from "../../../common/entities/DataValueStats"; import { Log } from "../usecases/UpdateMalApprovalStatusUseCase"; +import { DataSetWithConfigPermissions } from "../../../usecases/GetApprovalConfigurationsUseCase"; export interface MalDataApprovalRepository { get(options: MalDataApprovalOptions): Promise>; save(filename: string, dataSets: MalDataApprovalItem[]): Promise; complete(dataSets: MalDataApprovalItemIdentifier[]): Promise; - approve(dataSets: MalDataApprovalItemIdentifier[], log?: Log): Promise; + approve(options: { + dataSets: MalDataApprovalItemIdentifier[]; + log?: Log; + dataSetConfig: DataSetWithConfigPermissions; + }): Promise; duplicateDataSets( dataSets: MalDataApprovalItemIdentifier[], - dataElementsWithValues: DataDiffItemIdentifier[] + dataElementsWithValues: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions + ): Promise; + duplicateDataValues( + dataSets: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions + ): Promise; + replicateDataValuesInApvdDataSet(options: { + originalDataValues: DataDiffItemIdentifier[]; + dataSetConfig: DataSetWithConfigPermissions; + }): Promise; + duplicateDataValuesAndRevoke( + dataSets: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions ): Promise; - duplicateDataValues(dataSets: DataDiffItemIdentifier[]): Promise; - replicateDataValuesInApvdDataSet(dataSets: DataDiffItemIdentifier[]): Promise; - duplicateDataValuesAndRevoke(dataSets: DataDiffItemIdentifier[]): Promise; incomplete(dataSets: MalDataApprovalItemIdentifier[]): Promise; unapprove(dataSets: MalDataApprovalItemIdentifier[]): Promise; getColumns(namespace: string): Promise; @@ -38,4 +53,5 @@ export interface MalDataApprovalOptions { completionStatus?: boolean; isApproved?: boolean; modificationCount?: string | undefined; + dataSetsConfig: DataSetWithConfigPermissions[]; } diff --git a/src/domain/reports/mal-data-approval/repositories/UserGroupRepository.ts b/src/domain/reports/mal-data-approval/repositories/UserGroupRepository.ts index 44d83b3..9ae521b 100644 --- a/src/domain/reports/mal-data-approval/repositories/UserGroupRepository.ts +++ b/src/domain/reports/mal-data-approval/repositories/UserGroupRepository.ts @@ -1,7 +1,10 @@ import { Ref } from "../../../common/entities/Base"; +import { NamedRef } from "../../../common/entities/Ref"; +import { FutureData } from "../../../generic/Future"; export interface UserGroupRepository { getUserGroupByCode(code: string): Promise; + getByCodes(codes: string[]): FutureData; } -export type UserGroup = Ref; +export type UserGroup = NamedRef & { code: string }; diff --git a/src/domain/reports/mal-data-approval/usecases/ApproveMalDataValuesUseCase.ts b/src/domain/reports/mal-data-approval/usecases/ApproveMalDataValuesUseCase.ts index 7cd6fed..dc32aaa 100644 --- a/src/domain/reports/mal-data-approval/usecases/ApproveMalDataValuesUseCase.ts +++ b/src/domain/reports/mal-data-approval/usecases/ApproveMalDataValuesUseCase.ts @@ -2,13 +2,18 @@ import _ from "lodash"; import { UseCase } from "../../../../compositionRoot"; import { DataValueStats } from "../../../common/entities/DataValueStats"; import { DataSetRepository } from "../../../common/repositories/DataSetRepository"; +import { DataSetWithConfigPermissions } from "../../../usecases/GetApprovalConfigurationsUseCase"; import { DataDiffItemIdentifier } from "../entities/DataDiffItem"; import { MalDataApprovalRepository } from "../repositories/MalDataApprovalRepository"; export class ApproveMalDataValuesUseCase implements UseCase { constructor(private dataSetRepository: DataSetRepository, private approvalRepository: MalDataApprovalRepository) {} - async execute(items: DataDiffItemIdentifier[], approvalDataSetName: string): Promise { + async execute( + items: DataDiffItemIdentifier[], + dataSetConfig: DataSetWithConfigPermissions, + approvalDataSetName: string + ): Promise { const approvalDataSet = await this.dataSetRepository.getByNameOrCode(approvalDataSetName); const assignedOrgUnitIds = approvalDataSet.organisationUnits.map(ou => ou.id); const dataValuesOrgUnitIds = _(items) @@ -38,9 +43,10 @@ export class ApproveMalDataValuesUseCase implements UseCase { ? items.filter(item => (nonAssignedOrgUnits.includes(item.orgUnit) ? false : true)) : items; - const stats = await this.approvalRepository.replicateDataValuesInApvdDataSet( - dataValuesWithoutNonAssignedOrgUnits - ); + const stats = await this.approvalRepository.replicateDataValuesInApvdDataSet({ + dataSetConfig: dataSetConfig, + originalDataValues: dataValuesWithoutNonAssignedOrgUnits, + }); return stats.concat(notAssignedOrgUnitsStast); } diff --git a/src/domain/reports/mal-data-approval/usecases/DuplicateDataValuesUseCase.ts b/src/domain/reports/mal-data-approval/usecases/DuplicateDataValuesUseCase.ts index 5c5097a..b3102e8 100644 --- a/src/domain/reports/mal-data-approval/usecases/DuplicateDataValuesUseCase.ts +++ b/src/domain/reports/mal-data-approval/usecases/DuplicateDataValuesUseCase.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import { UseCase } from "../../../../compositionRoot"; import { DataSetStatusRepository } from "../../../common/repositories/DataSetStatusRepository"; +import { DataSetWithConfigPermissions } from "../../../usecases/GetApprovalConfigurationsUseCase"; import { DataDiffItemIdentifier } from "../entities/DataDiffItem"; import { MalDataApprovalRepository } from "../repositories/MalDataApprovalRepository"; import { DataSetUtils } from "./utils/dataSets"; @@ -11,14 +12,21 @@ export class DuplicateDataValuesUseCase implements UseCase { private dataSetStatusRepository: DataSetStatusRepository ) {} - async execute(items: DataDiffItemIdentifier[]): Promise { + async execute(items: DataDiffItemIdentifier[], dataSetsConfig: DataSetWithConfigPermissions[]): Promise { const groupItems = _(items) .map(item => ({ dataSetId: item.dataSet, orgUnitId: item.orgUnit, period: item.period })) .uniq() .value(); + const dataSetId = items[0]?.dataSet; + const dataSetConfig = dataSetsConfig.find(config => config.dataSet.id === dataSetId); + if (!dataSetConfig) throw new Error(`Data set configuration not found: ${dataSetId}`); + new DataSetUtils(this.dataSetStatusRepository).validateDataSetsStatus(groupItems); - const stats = await this.approvalRepository.replicateDataValuesInApvdDataSet(items); + const stats = await this.approvalRepository.replicateDataValuesInApvdDataSet({ + originalDataValues: items, + dataSetConfig, + }); return stats.filter(stat => stat.errorMessages.length > 0).length === 0; } } diff --git a/src/domain/reports/mal-data-approval/usecases/GetMalDataDiffUseCase.ts b/src/domain/reports/mal-data-approval/usecases/GetMalDataDiffUseCase.ts index 6634fe3..20654e6 100644 --- a/src/domain/reports/mal-data-approval/usecases/GetMalDataDiffUseCase.ts +++ b/src/domain/reports/mal-data-approval/usecases/GetMalDataDiffUseCase.ts @@ -1,7 +1,6 @@ import _ from "lodash"; import { UseCase } from "../../../../compositionRoot"; import { PaginatedObjects, Sorting } from "../../../common/entities/PaginatedObjects"; -import { AppSettingsRepository } from "../../../common/repositories/AppSettingsRepository"; import { DataSetRepository } from "../../../common/repositories/DataSetRepository"; import { DataValuesRepository } from "../../../common/repositories/DataValuesRepository"; import { WmrDiffReport } from "../../WmrDiffReport"; @@ -11,14 +10,9 @@ import { MalDataApprovalOptions } from "../repositories/MalDataApprovalRepositor type GetDataDiffUseCaseOptions = Omit & { sorting: Sorting }; export class GetMalDataDiffUseCase implements UseCase { - constructor( - private dataValueRepository: DataValuesRepository, - private dataSetRepository: DataSetRepository, - private appSettings: AppSettingsRepository - ) {} + constructor(private dataValueRepository: DataValuesRepository, private dataSetRepository: DataSetRepository) {} async execute(options: GetDataDiffUseCaseOptions): Promise> { - const settings = await this.appSettings.get(); const malariaDataSetId = options.dataSetId; const orgUnitId = _(options.orgUnitIds).first(); const period = _(options.periods).first(); @@ -29,7 +23,7 @@ export class GetMalDataDiffUseCase implements UseCase { const dataElementsWithValues = await new WmrDiffReport( this.dataValueRepository, this.dataSetRepository, - settings + options.dataSetsConfig ).getDiff(malariaDataSetId, orgUnitId, period); return { diff --git a/src/domain/reports/mal-data-approval/usecases/GetMalDataSetsUseCase.ts b/src/domain/reports/mal-data-approval/usecases/GetMalDataSetsUseCase.ts index c7aeb08..fa45727 100644 --- a/src/domain/reports/mal-data-approval/usecases/GetMalDataSetsUseCase.ts +++ b/src/domain/reports/mal-data-approval/usecases/GetMalDataSetsUseCase.ts @@ -2,14 +2,11 @@ import { UseCase } from "../../../../compositionRoot"; import { promiseMap } from "../../../../utils/promises"; import { Id } from "../../../common/entities/Base"; import { PaginatedObjects } from "../../../common/entities/PaginatedObjects"; -import { AppSettingsRepository } from "../../../common/repositories/AppSettingsRepository"; import { DataSetRepository } from "../../../common/repositories/DataSetRepository"; import { DataValuesRepository } from "../../../common/repositories/DataValuesRepository"; import { WmrDiffReport } from "../../WmrDiffReport"; import { MalDataApprovalItem } from "../entities/MalDataApprovalItem"; -import { getDataDuplicationItemMonitoringValue } from "../entities/MonitoringValue"; import { MalDataApprovalRepository, MalDataApprovalOptions } from "../repositories/MalDataApprovalRepository"; -import { MonitoringValueRepository } from "../repositories/MonitoringValueRepository"; type DataSetsOptions = Omit & { dataSetIds: Id[] }; @@ -17,35 +14,28 @@ export class GetMalDataSetsUseCase implements UseCase { constructor( private malDataRepository: MalDataApprovalRepository, private dataValueRepository: DataValuesRepository, - private dataSetRepository: DataSetRepository, - private monitoringValueRepository: MonitoringValueRepository, - private appSettingsRepository: AppSettingsRepository + private dataSetRepository: DataSetRepository ) {} - async execute( - monitoringNamespace: string, - options: DataSetsOptions - ): Promise> { - const appSettings = await this.appSettingsRepository.get(); - + async execute(options: DataSetsOptions): Promise> { if (options.dataSetIds.length === 0) return Promise.resolve({ objects: [], pager: { page: 0, pageCount: 0, pageSize: 0, total: 0 } }); const allRecords = await promiseMap(options.dataSetIds, async dataSetId => { const { dataSetIds: _, ...rest } = options; const result = await this.malDataRepository.get({ ...rest, dataSetId: dataSetId }); - const monitoringValue = await this.monitoringValueRepository.get(monitoringNamespace); const response = await promiseMap(result.objects, async item => { const dataElementsWithValues = await new WmrDiffReport( this.dataValueRepository, this.dataSetRepository, - appSettings + options.dataSetsConfig ).getDiff(item.dataSetUid, item.orgUnitUid, item.period); return { ...item, - monitoring: monitoringValue ? getDataDuplicationItemMonitoringValue(item, monitoringValue) : false, + // TODO: remove all monitoring related code + monitoring: false, modificationCount: dataElementsWithValues.length > 0 ? String(dataElementsWithValues.length) : "", }; }); diff --git a/src/domain/reports/mal-data-approval/usecases/UpdateMalApprovalStatusUseCase.ts b/src/domain/reports/mal-data-approval/usecases/UpdateMalApprovalStatusUseCase.ts index 0c57c57..cdae426 100644 --- a/src/domain/reports/mal-data-approval/usecases/UpdateMalApprovalStatusUseCase.ts +++ b/src/domain/reports/mal-data-approval/usecases/UpdateMalApprovalStatusUseCase.ts @@ -6,17 +6,22 @@ import { WmrDiffReport } from "../../WmrDiffReport"; import { MalDataApprovalItemIdentifier } from "../entities/MalDataApprovalItem"; import { MalDataApprovalRepository } from "../repositories/MalDataApprovalRepository"; import { DataDiffItemIdentifier } from "../entities/DataDiffItem"; -import { AppSettingsRepository } from "../../../common/repositories/AppSettingsRepository"; +import { DataSetWithConfigPermissions } from "../../../usecases/GetApprovalConfigurationsUseCase"; export class UpdateMalApprovalStatusUseCase { constructor( private approvalRepository: MalDataApprovalRepository, private dataValueRepository: DataValuesRepository, - private dataSetRepository: DataSetRepository, - private appSettingsRepository: AppSettingsRepository + private dataSetRepository: DataSetRepository ) {} - async execute(items: MalDataApprovalItemIdentifier[], action: UpdateAction, log?: Log): Promise { + async execute(options: { + items: MalDataApprovalItemIdentifier[]; + action: UpdateAction; + log?: Log; + dataSetsConfig: DataSetWithConfigPermissions[]; + }): Promise { + const { items, action, log, dataSetsConfig } = options; const itemsByDataSet = _(items) .groupBy(item => item.dataSet) .value(); @@ -28,26 +33,31 @@ export class UpdateMalApprovalStatusUseCase { const result = await promiseMap(dataSetIds, async dataSetId => { const itemsToUpdate = itemsByDataSet[dataSetId]; - if (!itemsToUpdate) return true; + const config = dataSetsConfig.find(config => config.dataSet.id === dataSetId); + if (!itemsToUpdate || !config) return true; switch (action) { case "complete": return this.approvalRepository.complete(itemsToUpdate); case "approve": // "Submit" in UI - return this.approvalRepository.approve(itemsToUpdate, log); + return this.approvalRepository.approve({ dataSets: itemsToUpdate, log, dataSetConfig: config }); case "duplicate": { // "Approve" in UI - const dataElementsWithValues = await this.getDataElementsToDuplicate(itemsToUpdate); - const stats = await this.approvalRepository.replicateDataValuesInApvdDataSet( - dataElementsWithValues - ); + const dataElementsWithValues = await this.getDataElementsToDuplicate(itemsToUpdate, dataSetsConfig); + const stats = await this.approvalRepository.replicateDataValuesInApvdDataSet({ + originalDataValues: dataElementsWithValues, + dataSetConfig: config, + }); return stats.filter(stats => stats.errorMessages.length > 0).length === 0; } case "revoke": { const revokeResult = await this.approvalRepository.unapprove(itemsToUpdate); - const incompleteResult = await this.approvalRepository.incomplete(itemsToUpdate); - return revokeResult && incompleteResult; + if (config.configuration.revokeAndIncomplete) { + const incompleteResult = await this.approvalRepository.incomplete(itemsToUpdate); + return revokeResult && incompleteResult; + } + return revokeResult; } case "incomplete": return this.approvalRepository.incomplete(itemsToUpdate); @@ -60,11 +70,11 @@ export class UpdateMalApprovalStatusUseCase { } private async getDataElementsToDuplicate( - items: MalDataApprovalItemIdentifier[] + items: MalDataApprovalItemIdentifier[], + dataSetsConfig: DataSetWithConfigPermissions[] ): Promise { - const settings = await this.appSettingsRepository.get(); const dataElementsWithValues = await promiseMap(items, async item => { - return await new WmrDiffReport(this.dataValueRepository, this.dataSetRepository, settings).getDiff( + return await new WmrDiffReport(this.dataValueRepository, this.dataSetRepository, dataSetsConfig).getDiff( item.dataSet, item.orgUnit, item.period diff --git a/src/domain/reports/nhwa-auto-complete-compute/usecases/FixAutoCompleteComputeValuesUseCase.ts b/src/domain/reports/nhwa-auto-complete-compute/usecases/FixAutoCompleteComputeValuesUseCase.ts deleted file mode 100644 index 960391d..0000000 --- a/src/domain/reports/nhwa-auto-complete-compute/usecases/FixAutoCompleteComputeValuesUseCase.ts +++ /dev/null @@ -1,47 +0,0 @@ -import _ from "lodash"; -import { AutoCompleteComputeViewModel } from "../../../../webapp/reports/nhwa-auto-complete-compute/NHWAAutoCompleteCompute"; -import { DataValueToPost } from "../../../common/entities/DataValue"; -import { Stats } from "../../../common/entities/Stats"; -import { DataValuesRepository } from "../../../common/repositories/DataValuesRepository"; - -export class FixAutoCompleteComputeValuesUseCase { - constructor(private dataValuesRepository: DataValuesRepository) {} - - async execute(values: AutoCompleteComputeViewModel[]): Promise { - const dataValuesToDelete = values.filter(dv => dv.valueToFix === "Empty").map(this.convertToDataValueToPost()); - - const dataValuesToSave: DataValueToPost[] = values - .filter(dv => dv.valueToFix !== "Empty") - .map(this.convertToDataValueToPost()); - - const statsSaved = await this.dataValuesRepository.saveAll(dataValuesToSave); - const statsDeleted = await this.dataValuesRepository.deleteAll(dataValuesToDelete); - - return { - deleted: statsSaved.deleted + statsDeleted.deleted, - imported: statsSaved.imported + statsDeleted.imported, - updated: statsSaved.updated + statsDeleted.updated, - ignored: statsSaved.ignored + statsDeleted.ignored, - errorMessages: _(statsSaved.errorMessages) - .concat(statsDeleted.errorMessages) - .uniqBy(message => message.message) - .value(), - }; - } - - private convertToDataValueToPost(): ( - value: AutoCompleteComputeViewModel, - index: number, - array: AutoCompleteComputeViewModel[] - ) => { categoryOptionCombo: string; dataElement: string; period: string; orgUnit: string; value: string } { - return dv => { - return { - categoryOptionCombo: dv.categoryOptionCombo.id, - dataElement: dv.dataElement.id, - period: dv.period, - orgUnit: dv.orgUnit.id, - value: dv.valueToFix === "Empty" ? "" : dv.valueToFix, - }; - }; - } -} diff --git a/src/domain/reports/nhwa-auto-complete-compute/usecases/GetAutoCompleteComputeValuesUseCase.ts b/src/domain/reports/nhwa-auto-complete-compute/usecases/GetAutoCompleteComputeValuesUseCase.ts deleted file mode 100644 index e3938a5..0000000 --- a/src/domain/reports/nhwa-auto-complete-compute/usecases/GetAutoCompleteComputeValuesUseCase.ts +++ /dev/null @@ -1,221 +0,0 @@ -import _ from "lodash"; -import { - AutoCompleteComputeViewModel, - AutoCompleteComputeViewModelWithPaging, -} from "../../../../webapp/reports/nhwa-auto-complete-compute/NHWAAutoCompleteCompute"; -import { CategoryOptionCombo, DataElement } from "../../../common/entities/DataSet"; -import { DataSetRepository } from "../../../common/repositories/DataSetRepository"; -import { DataValuesRepository } from "../../../common/repositories/DataValuesRepository"; -import { AutoCompleteComputeSettings, DataElementTotal } from "../entities/AutoCompleteComputeSettings"; -import { DataValue } from "./../../../common/entities/DataValue"; -import { promiseMap } from "../../../../utils/promises"; - -export type AutoCompleteComputeValuesFilter = { - cacheKey: string; - page: number; - pageSize: number; - sortingField: string; - sortingOrder: "asc" | "desc"; - filters: { periods: string[]; orgUnits: string[] }; - settings: AutoCompleteComputeSettings; -}; - -export class GetAutoCompleteComputeValuesUseCase { - dataCache: { key: string; value: AutoCompleteComputeViewModel[] } | undefined; - - constructor(private dataSetRepository: DataSetRepository, private dataValuesRepository: DataValuesRepository) {} - - async execute(options: AutoCompleteComputeValuesFilter): Promise { - const autoCompleteValues = await this.getAllAutoCompleteValues(options); - const sortValues = _(autoCompleteValues) - .orderBy(this.getSortField(options.sortingField), options.sortingOrder) - .value(); - const { rows, page, pageSize } = this.getPaginatedItems(sortValues, options.page, options.pageSize); - return { - page, - pageSize, - pageCount: Math.ceil(autoCompleteValues.length / pageSize), - total: autoCompleteValues.length, - rows: _(rows).orderBy(this.getSortField(options.sortingField), options.sortingOrder).value(), - }; - } - - private async getAllAutoCompleteValues( - options: AutoCompleteComputeValuesFilter - ): Promise { - const { cacheKey, filters, settings } = options; - if (this.dataCache && this.dataCache.key === cacheKey) return this.dataCache.value; - - const dataSets = await this.dataSetRepository.getById(settings.dataSet); - const dataSet = dataSets[0]; - if (!dataSet) return []; - - const orgUnitsByKey = _(dataSet.organisationUnits) - .keyBy(ou => ou.id) - .value(); - - const dataElementsByKey = _(dataSet.dataElements) - .keyBy(de => de.id) - .value(); - - const orgUnitsToRequest = filters.orgUnits.length - ? filters.orgUnits - : dataSet.organisationUnits.map(ou => ou.id); - - const dataValues = await promiseMap(_.chunk(orgUnitsToRequest, 50), async orgUnits => { - const currentPeriods = filters.periods.length ? filters.periods : undefined; - const dataValuesPerOrgUnit = await this.dataValuesRepository.get({ - dataSetIds: [dataSet.id], - orgUnitIds: orgUnits, - periods: currentPeriods, - startDate: currentPeriods ? undefined : "1800", - endDate: currentPeriods ? undefined : "2100", - }); - return dataValuesPerOrgUnit; - }); - - const dvByOrgUnitAndPeriods = _(dataValues) - .flatten() - .groupBy(dv => `${dv.orgUnit}.${dv.period}`) - .value(); - - const keys = _(dvByOrgUnitAndPeriods).keys().value(); - const results = _(keys) - .map(key => { - const dataValuesOrgPeriod = dvByOrgUnitAndPeriods[key]; - const [orgUnit, period] = key.split("."); - if (!orgUnit || !period) { - throw Error("Cannot found orgUnit or period"); - } - - if (dataValuesOrgPeriod) { - const rows = _(settings.dataElements) - .map(dataElement => { - const deDetails = this.getDataElementDetails( - dataElementsByKey, - dataElement.dataElementTotal - ); - - const catOptionCombo = deDetails.categoryCombo.categoryOptionCombos.find( - category => category.id === dataElement.categoryOptionCombo - ); - const deTotal = dataValuesOrgPeriod.find( - dv => dv.dataElement === deDetails.id && dv.categoryOptionCombo === catOptionCombo?.id - ); - - const deChildren = this.getChildrenValues( - dataElement, - dataElementsByKey, - dataValuesOrgPeriod, - catOptionCombo - ); - - const allChildrenAreEmpty = deChildren.every(de => de.value === "" || _.isNull(de.value)); - - const valueToFix = allChildrenAreEmpty - ? "Empty" - : _(deChildren) - .compact() - .sumBy(de => Number(de.value) || 0); - - const childrenResultSum = _(deChildren) - .map(de => de.value || "Empty") - .join(" + "); - - const correctValue = `${childrenResultSum} = ${valueToFix}`; - - if ((allChildrenAreEmpty && !deTotal?.value) || valueToFix === Number(deTotal?.value)) { - return undefined; - } - - return { - id: `${key}.${deDetails.id}.${catOptionCombo?.id}`, - dataElement: { - id: deDetails.id, - name: deDetails.name, - }, - orgUnit: { - id: orgUnit, - name: orgUnitsByKey[orgUnit]?.name || "", - }, - period, - categoryOptionCombo: { - id: catOptionCombo?.id || "", - name: catOptionCombo?.name || "", - }, - correctValue, - valueToFix: String(valueToFix), - currentValue: deTotal?.value, - }; - }) - .compact() - .value(); - return rows; - } - return undefined; - }) - .compact() - .flatten() - .value(); - - this.dataCache = { - key: cacheKey, - value: results, - }; - - return this.dataCache.value; - } - - private getChildrenValues( - dataElementTotal: DataElementTotal, - dataElements: Record, - dataValuesOrgPeriod: DataValue[], - catOptionCombo: CategoryOptionCombo | undefined - ) { - return _(dataElementTotal.children) - .map(deChild => { - const deDetails = this.getDataElementDetails(dataElements, deChild.dataElement); - const currentDe = dataValuesOrgPeriod.find( - dv => dv.dataElement === deDetails.id && dv.categoryOptionCombo === catOptionCombo?.id - ); - return { - dataElement: currentDe?.dataElement || deChild.dataElement, - value: currentDe ? currentDe.value : "", - }; - }) - .value(); - } - - private getDataElementDetails(dataElements: Record, dataElementName: string): DataElement { - const deDetails = dataElements[dataElementName]; - if (!deDetails) { - throw Error(`Cannot found data element: ${dataElementName}`); - } - return deDetails; - } - - private getPaginatedItems(items: AutoCompleteComputeViewModel[], page: number, pageSize: number) { - const pg = page, - pgSize = pageSize, - offset = (pg - 1) * pgSize, - pagedItems = _.drop(items, offset).slice(0, pgSize); - return { - page: pg, - pageSize: pgSize, - total: items.length, - totalPages: Math.ceil(items.length / pgSize), - rows: pagedItems, - }; - } - - private getSortField(fieldName: string) { - if (fieldName === "orgUnit") { - return "orgUnit.name"; - } else if (fieldName === "categoryOptionCombo") { - return "categoryOptionCombo.name"; - } else if (fieldName === "dataElement") { - return "dataElement.name"; - } - return fieldName; - } -} diff --git a/src/domain/reports/nhwa-fix-totals/GetTotalsByActivityLevelUseCase.ts b/src/domain/reports/nhwa-fix-totals/GetTotalsByActivityLevelUseCase.ts deleted file mode 100644 index 2bb7bc4..0000000 --- a/src/domain/reports/nhwa-fix-totals/GetTotalsByActivityLevelUseCase.ts +++ /dev/null @@ -1,212 +0,0 @@ -import _ from "lodash"; - -import { DataSetRepository } from "../../common/repositories/DataSetRepository"; -import { DataValuesRepository } from "../../common/repositories/DataValuesRepository"; -import { - FixTotalsViewModel, - FixTotalsWithPaging, -} from "../../../webapp/reports/nhwa-fix-totals-activity-level/NHWAFixTotals"; -import { Id } from "../../common/entities/Base"; -import { defaultPeriods } from "../../../webapp/reports/common/nhwa-settings"; -import { promiseMap } from "../../../utils/promises"; -import { DataValue } from "../../common/entities/DataValue"; -import { FixTotalsSettingsRepository } from "./repositories/FixTotalsSettingsRepository"; - -export type FixTotalsFilter = { - page: number; - pageSize: number; - sortingField: string; - sortingOrder: "asc" | "desc"; - filters: { periods: string[]; orgUnits: string[] }; -}; - -export class GetTotalsByActivityLevelUseCase { - constructor( - private dataSetRepository: DataSetRepository, - private dataValuesRepository: DataValuesRepository, - private settingsRepository: FixTotalsSettingsRepository - ) {} - - async execute(options: FixTotalsFilter): Promise { - const autoCompleteValues = await this.getAllAutoCompleteValues(options); - const sortValues = _(autoCompleteValues) - .orderBy(this.getSortField(options.sortingField), options.sortingOrder) - .value(); - const { rows, page, pageSize } = this.getPaginatedItems(sortValues, options.page, options.pageSize); - return { - page, - pageSize, - pageCount: Math.ceil(autoCompleteValues.length / pageSize), - total: autoCompleteValues.length, - rows: _(rows).orderBy(this.getSortField(options.sortingField), options.sortingOrder).value(), - }; - } - - private async getAllAutoCompleteValues(options: FixTotalsFilter): Promise { - const { filters } = options; - - const fixTotalSettings = await this.settingsRepository.get(); - const dataSets = await this.dataSetRepository.getById(fixTotalSettings.dataSet); - const dataSet = dataSets[0]; - if (!dataSet) return []; - - const orgUnitsByKey = _(dataSet.organisationUnits) - .keyBy(ou => ou.id) - .value(); - - const dataElementsByKey = _(dataSet.dataElements) - .keyBy(de => de.id) - .value(); - - const orgUnitsToRequest = filters.orgUnits.length - ? filters.orgUnits - : dataSet.organisationUnits.map(ou => ou.id); - - const dataValues = await promiseMap(_.chunk(orgUnitsToRequest, 50), async orgUnits => { - const dataValuesPerOrgUnit = await this.dataValuesRepository.get({ - dataSetIds: [dataSet.id], - orgUnitIds: orgUnits, - periods: filters.periods.length ? filters.periods : defaultPeriods.map(x => x.value), - }); - return dataValuesPerOrgUnit; - }); - - const dvByOrgUnitAndPeriods = _(dataValues) - .flatten() - .filter(dv => { - return dataElementsByKey[dv.dataElement] ? true : false; - }) - .groupBy(dv => `${dv.orgUnit}.${dv.period}`) - .value(); - - const keys = _(dvByOrgUnitAndPeriods).keys().value(); - - const dataElementsAllowed = fixTotalSettings.dataElements.filter( - de => !fixTotalSettings.excludeTotalDataElements.includes(de.total.id) - ); - - const results = _(keys) - .map(key => { - const dataValuesOrgPeriod = dvByOrgUnitAndPeriods[key]; - const [orgUnit, period] = key.split("."); - if (!orgUnit || !period) { - throw Error("Cannot found orgUnit or period"); - } - if (dataValuesOrgPeriod) { - const rows = _(dataElementsAllowed) - .map(dataElement => { - const totalDataElement = this.getDataValueDetails( - dataValuesOrgPeriod, - dataElement.total.id, - dataElement.total.cocId - ); - - // if total is already defined ignore the record - if (totalDataElement?.value) return undefined; - - const practisingDe = this.getDataValueDetails( - dataValuesOrgPeriod, - dataElement.practising.id, - dataElement.practising.cocId - ); - const practisingValue = practisingDe?.value; - - const professionalDe = this.getDataValueDetails( - dataValuesOrgPeriod, - dataElement.professionallyActive.id, - dataElement.professionallyActive.cocId - ); - const professionalValue = professionalDe?.value; - - const licensedToPracticeDe = this.getDataValueDetails( - dataValuesOrgPeriod, - dataElement.licensedToPractise.id, - dataElement.licensedToPractise.cocId - ); - const licensedToPracticeValue = licensedToPracticeDe?.value; - - const comment = this.getDataValueComment( - practisingValue, - professionalValue, - licensedToPracticeValue - ); - - if (!comment) return undefined; - - const dataElementTotalDetails = dataElementsByKey[dataElement.total.id]; - if (!dataElementTotalDetails) return undefined; - - const row: FixTotalsViewModel = { - id: `${key}.${dataElement.total.id}.${dataElement.total.cocId}`, - dataElement: dataElementTotalDetails, - period: period, - orgUnit: { - id: orgUnit, - name: orgUnitsByKey[orgUnit]?.name || "", - }, - practising: practisingValue || "", - professionallyActive: professionalValue || "", - licensedToPractice: licensedToPracticeValue || "", - total: totalDataElement ? totalDataElement.value : "", - correctTotal: practisingValue || professionalValue || licensedToPracticeValue || "", - comment, - }; - - return row; - }) - .compact() - .value(); - return rows; - } - return undefined; - }) - .compact() - .flatten() - .value(); - - return results; - } - - private getDataValueComment( - practisingValue: string | undefined, - professionalValue: string | undefined, - licensedToPracticeValue: string | undefined - ) { - let comment = ""; - if (practisingValue) { - comment = "Value obtained from Practising"; - } else if (professionalValue) { - comment = "Value obtained from Professionally Active"; - } else if (licensedToPracticeValue) { - comment = "Value obtained from Licensed to Practice"; - } - return comment; - } - - private getDataValueDetails(dataValuesOrgPeriod: DataValue[], dataElementId: Id, cocId: Id) { - return dataValuesOrgPeriod.find(dv => dv.dataElement === dataElementId && dv.categoryOptionCombo === cocId); - } - - private getPaginatedItems(items: FixTotalsViewModel[], page: number, pageSize: number) { - const pg = page, - pgSize = pageSize, - offset = (pg - 1) * pgSize, - pagedItems = _.drop(items, offset).slice(0, pgSize); - return { - page: pg, - pageSize: pgSize, - total: items.length, - totalPages: Math.ceil(items.length / pgSize), - rows: pagedItems, - }; - } - - private getSortField(fieldName: string) { - if (fieldName === "orgUnit") { - return "orgUnit.name"; - } else if (fieldName === "dataElement") { - return "dataElement.name"; - } - return fieldName; - } -} diff --git a/src/domain/reports/nhwa-fix-totals/usecases/FixTotalsValuesUseCase.ts b/src/domain/reports/nhwa-fix-totals/usecases/FixTotalsValuesUseCase.ts deleted file mode 100644 index e71c42f..0000000 --- a/src/domain/reports/nhwa-fix-totals/usecases/FixTotalsValuesUseCase.ts +++ /dev/null @@ -1,30 +0,0 @@ -import _ from "lodash"; -import { FixTotalsViewModel } from "../../../../webapp/reports/nhwa-fix-totals-activity-level/NHWAFixTotals"; -import { DataValueToPost } from "../../../common/entities/DataValue"; -import { Stats } from "../../../common/entities/Stats"; -import { DataValuesRepository } from "../../../common/repositories/DataValuesRepository"; - -export class FixTotalsValuesUseCase { - constructor(private dataValuesRepository: DataValuesRepository) {} - - async execute(values: FixTotalsViewModel[]): Promise { - const dataValuesToSave = values.map(dv => this.convertToDataValueToPost(dv)); - const statsSaved = await this.dataValuesRepository.saveAll(dataValuesToSave); - return statsSaved; - } - - private convertToDataValueToPost(fixTotal: FixTotalsViewModel): DataValueToPost { - const cocId = _(fixTotal.id).split(".").last(); - if (!cocId) { - throw Error("Could not found category option combo"); - } - return { - categoryOptionCombo: cocId, - dataElement: fixTotal.dataElement.id, - period: fixTotal.period, - orgUnit: fixTotal.orgUnit.id, - value: fixTotal.correctTotal, - comment: fixTotal.comment, - }; - } -} diff --git a/src/domain/repositories/DataSetConfigurationRepository.ts b/src/domain/repositories/DataSetConfigurationRepository.ts new file mode 100644 index 0000000..bd811cf --- /dev/null +++ b/src/domain/repositories/DataSetConfigurationRepository.ts @@ -0,0 +1,9 @@ +import { DataSetConfiguration } from "../entities/DataSetConfiguration"; +import { FutureData } from "../generic/Future"; + +export interface DataSetConfigurationRepository { + getByCode(code: string): FutureData; + getAll(): FutureData; + save(configuration: DataSetConfiguration): FutureData; + remove(id: string): FutureData; +} diff --git a/src/domain/repositories/MetadataEntityRepository.ts b/src/domain/repositories/MetadataEntityRepository.ts new file mode 100644 index 0000000..4308694 --- /dev/null +++ b/src/domain/repositories/MetadataEntityRepository.ts @@ -0,0 +1,17 @@ +import { PaginatedObjects } from "../common/entities/PaginatedObjects"; +import { MetadataEntity } from "../entities/MetadataEntity"; +import { FutureData } from "../generic/Future"; + +export interface MetadataEntityRepository { + getBy(options: GetMetadataEntityOptions): FutureData>; +} + +export type GetMetadataEntityOptions = { + type: MetadataEntityType; + page: number; + pageSize: number; + search: string; + onlyWithCode: boolean; +}; + +export type MetadataEntityType = "dataElements" | "dataSets" | "sqlViews"; diff --git a/src/domain/repositories/UserRepository.ts b/src/domain/repositories/UserRepository.ts new file mode 100644 index 0000000..e3bab1f --- /dev/null +++ b/src/domain/repositories/UserRepository.ts @@ -0,0 +1,7 @@ +import { User } from "../entities/User"; +import { FutureData } from "../generic/Future"; + +export interface UserRepository { + getCurrent(): FutureData; + getByUsernames(usernames: string[]): FutureData; +} diff --git a/src/domain/repositories/UserSharingRepository.ts b/src/domain/repositories/UserSharingRepository.ts new file mode 100644 index 0000000..ed506f4 --- /dev/null +++ b/src/domain/repositories/UserSharingRepository.ts @@ -0,0 +1,6 @@ +import { UserSharing } from "../entities/UserSharing"; +import { FutureData } from "../generic/Future"; + +export interface UserSharingRepository { + get(query: string): FutureData; +} diff --git a/src/domain/usecases/GetAppSettingsUseCase.ts b/src/domain/usecases/GetAppSettingsUseCase.ts deleted file mode 100644 index 996a6b4..0000000 --- a/src/domain/usecases/GetAppSettingsUseCase.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { AppSettings } from "../common/entities/AppSettings"; -import { AppSettingsRepository } from "../common/repositories/AppSettingsRepository"; - -export class GetAppSettingsUseCase { - constructor(private appSettingsRepository: AppSettingsRepository) {} - - execute(): Promise { - return this.appSettingsRepository.get(); - } -} diff --git a/src/domain/usecases/GetApprovalConfigurationsUseCase.ts b/src/domain/usecases/GetApprovalConfigurationsUseCase.ts new file mode 100644 index 0000000..e925f62 --- /dev/null +++ b/src/domain/usecases/GetApprovalConfigurationsUseCase.ts @@ -0,0 +1,53 @@ +import { DataSet } from "../common/entities/DataSet"; +import { DataSetRepository } from "../common/repositories/DataSetRepository"; +import { DataSetConfiguration } from "../entities/DataSetConfiguration"; +import { FutureData } from "../generic/Future"; +import { DataSetConfigurationRepository } from "../repositories/DataSetConfigurationRepository"; +import { UserRepository } from "../repositories/UserRepository"; +import { UCDataSetConfiguration } from "./helpers/UCDataSetConfiguration"; +import _ from "../generic/Collection"; +import { Maybe } from "../../types/utils"; + +export class GetApprovalConfigurationsUseCase { + private UCDataSetConfiguration: UCDataSetConfiguration; + constructor( + private options: { + dataSetConfigurationRepository: DataSetConfigurationRepository; + userRepository: UserRepository; + dataSetRepository: DataSetRepository; + } + ) { + this.UCDataSetConfiguration = new UCDataSetConfiguration({ + dataSetConfigurationRepository: this.options.dataSetConfigurationRepository, + userRepository: this.options.userRepository, + dataSetRepository: this.options.dataSetRepository, + }); + } + + execute(): FutureData { + return this.UCDataSetConfiguration.getConfigurations().flatMap(configurations => { + const dataSetCodes = configurations.map(config => config.dataSetOriginalCode); + + return this.getDataSets(dataSetCodes).map(dataSets => { + return _(dataSets) + .filter(ds => ds.periodType !== undefined) + .compactMap((dataSet): Maybe => { + const config = configurations.find(config => config.dataSetOriginalCode === dataSet.code); + if (!config) return undefined; + + return { configuration: config, dataSet: dataSet }; + }) + .value(); + }); + }); + } + + private getDataSets(codes: string[]): FutureData { + return this.options.dataSetRepository.getByCodes(codes); + } +} + +export type DataSetWithConfigPermissions = { + dataSet: DataSet; + configuration: DataSetConfiguration; +}; diff --git a/src/domain/usecases/GetDataSetConfigurationByCodeUseCase.ts b/src/domain/usecases/GetDataSetConfigurationByCodeUseCase.ts new file mode 100644 index 0000000..755f06b --- /dev/null +++ b/src/domain/usecases/GetDataSetConfigurationByCodeUseCase.ts @@ -0,0 +1,39 @@ +import { DataSetConfiguration } from "../entities/DataSetConfiguration"; +import { Future, FutureData } from "../generic/Future"; +import { DataSetConfigurationRepository } from "../repositories/DataSetConfigurationRepository"; +import { UserRepository } from "../repositories/UserRepository"; + +export class GetDataSetConfigurationByCodeUseCase { + constructor( + private options: { + dataSetConfigurationRepository: DataSetConfigurationRepository; + userRepository: UserRepository; + } + ) {} + + execute(options: { id: string }): FutureData { + const { id } = options; + return this.options.userRepository.getCurrent().flatMap(currentUser => { + const userGroupIds = currentUser.userGroups.map(group => group.code); + + return this.options.dataSetConfigurationRepository.getByCode(id).flatMap(dataSetConfig => { + if (currentUser.isSuperAdmin) { + return Future.success(dataSetConfig); + } + + const hasPermission = dataSetConfig.canUserPerformAction( + "read", + currentUser.username, + userGroupIds, + currentUser.isSuperAdmin + ); + + if (!hasPermission) { + return Future.error(new Error("You do not have permission to access this DataSet configuration")); + } + + return Future.success(dataSetConfig); + }); + }); + } +} diff --git a/src/domain/usecases/GetDataSetConfigurationsUseCase.ts b/src/domain/usecases/GetDataSetConfigurationsUseCase.ts new file mode 100644 index 0000000..086ac9b --- /dev/null +++ b/src/domain/usecases/GetDataSetConfigurationsUseCase.ts @@ -0,0 +1,27 @@ +import { DataSetRepository } from "../common/repositories/DataSetRepository"; +import { DataSetConfiguration } from "../entities/DataSetConfiguration"; +import { FutureData } from "../generic/Future"; +import { DataSetConfigurationRepository } from "../repositories/DataSetConfigurationRepository"; +import { UserRepository } from "../repositories/UserRepository"; +import { UCDataSetConfiguration } from "./helpers/UCDataSetConfiguration"; + +export class GetDataSetConfigurationsUseCase { + private UCDataSetConfiguration: UCDataSetConfiguration; + constructor( + private options: { + dataSetConfigurationRepository: DataSetConfigurationRepository; + userRepository: UserRepository; + dataSetRepository: DataSetRepository; + } + ) { + this.UCDataSetConfiguration = new UCDataSetConfiguration({ + dataSetConfigurationRepository: this.options.dataSetConfigurationRepository, + userRepository: this.options.userRepository, + dataSetRepository: this.options.dataSetRepository, + }); + } + + execute(): FutureData { + return this.UCDataSetConfiguration.getConfigurations(); + } +} diff --git a/src/domain/usecases/GetMetadataEntitiesUseCase.ts b/src/domain/usecases/GetMetadataEntitiesUseCase.ts new file mode 100644 index 0000000..01bb2d3 --- /dev/null +++ b/src/domain/usecases/GetMetadataEntitiesUseCase.ts @@ -0,0 +1,12 @@ +import { PaginatedObjects } from "../common/entities/PaginatedObjects"; +import { MetadataEntity } from "../entities/MetadataEntity"; +import { FutureData } from "../generic/Future"; +import { GetMetadataEntityOptions, MetadataEntityRepository } from "../repositories/MetadataEntityRepository"; + +export class GetMetadataEntitiesUseCase { + constructor(private options: { metadataEntityRepository: MetadataEntityRepository }) {} + + execute(options: GetMetadataEntityOptions): FutureData> { + return this.options.metadataEntityRepository.getBy(options); + } +} diff --git a/src/domain/usecases/GetUserGroupsByCodeUseCase.ts b/src/domain/usecases/GetUserGroupsByCodeUseCase.ts new file mode 100644 index 0000000..9d07c2a --- /dev/null +++ b/src/domain/usecases/GetUserGroupsByCodeUseCase.ts @@ -0,0 +1,10 @@ +import { FutureData } from "../generic/Future"; +import { UserGroupRepository, UserGroup } from "../reports/mal-data-approval/repositories/UserGroupRepository"; + +export class GetUserGroupsByCodeUseCase { + constructor(private options: { userGroupRepository: UserGroupRepository }) {} + + public execute(codes: string[]): FutureData { + return this.options.userGroupRepository.getByCodes(codes); + } +} diff --git a/src/domain/usecases/GetUsersByUsernameUseCase.ts b/src/domain/usecases/GetUsersByUsernameUseCase.ts new file mode 100644 index 0000000..5d02c7c --- /dev/null +++ b/src/domain/usecases/GetUsersByUsernameUseCase.ts @@ -0,0 +1,11 @@ +import { User } from "../entities/User"; +import { FutureData } from "../generic/Future"; +import { UserRepository } from "../repositories/UserRepository"; + +export class GetUsersByUsernameUseCase { + constructor(private options: { userRepository: UserRepository }) {} + + public execute(usernames: string[]): FutureData { + return this.options.userRepository.getByUsernames(usernames); + } +} diff --git a/src/domain/usecases/RemoveDataSetConfigurationUseCase.ts b/src/domain/usecases/RemoveDataSetConfigurationUseCase.ts new file mode 100644 index 0000000..406cd54 --- /dev/null +++ b/src/domain/usecases/RemoveDataSetConfigurationUseCase.ts @@ -0,0 +1,22 @@ +import { Future, FutureData } from "../generic/Future"; +import { DataSetConfigurationRepository } from "../repositories/DataSetConfigurationRepository"; +import { UserRepository } from "../repositories/UserRepository"; + +export class RemoveDataSetConfigurationUseCase { + constructor( + private options: { + dataSetConfigurationRepository: DataSetConfigurationRepository; + userRepository: UserRepository; + } + ) {} + + execute(id: string): FutureData { + return this.options.userRepository.getCurrent().flatMap(currentUser => { + if (!currentUser.isSuperAdmin) { + return Future.error(new Error("Only super administrators can remove DataSet configurations")); + } + + return this.options.dataSetConfigurationRepository.remove(id); + }); + } +} diff --git a/src/domain/usecases/SaveDataSetConfigurationUseCase.ts b/src/domain/usecases/SaveDataSetConfigurationUseCase.ts new file mode 100644 index 0000000..7f09b98 --- /dev/null +++ b/src/domain/usecases/SaveDataSetConfigurationUseCase.ts @@ -0,0 +1,43 @@ +import { DataSetConfiguration } from "../entities/DataSetConfiguration"; +import { Future, FutureData } from "../generic/Future"; +import { DataSetConfigurationRepository } from "../repositories/DataSetConfigurationRepository"; +import { UserRepository } from "../repositories/UserRepository"; + +export class SaveDataSetConfigurationUseCase { + constructor( + private options: { + dataSetConfigurationRepository: DataSetConfigurationRepository; + userRepository: UserRepository; + } + ) {} + + execute(configuration: DataSetConfiguration): FutureData { + const dsAreEqual = configuration.dataSetsAreEqual(); + if (dsAreEqual) { + return Future.error(new Error("The original and destination DataSet cannot be the same")); + } + + return this.options.userRepository.getCurrent().flatMap(currentUser => { + if (!currentUser.isSuperAdmin) { + return Future.error(new Error("Only super administrators can save DataSet configurations")); + } + + return this.checkDuplicate(configuration).flatMap(isDuplicate => { + if (isDuplicate) { + return Future.error(new Error("A configuration with the same code already exists")); + } else { + return this.options.dataSetConfigurationRepository.save(configuration); + } + }); + }); + } + + private checkDuplicate(configuration: DataSetConfiguration): FutureData { + return this.options.dataSetConfigurationRepository + .getByCode(configuration.id) + .map(existing => { + return existing.id !== configuration.id; + }) + .flatMapError(() => Future.success(false)); + } +} diff --git a/src/domain/usecases/SearchUsersAndUserGroupsUseCase.ts b/src/domain/usecases/SearchUsersAndUserGroupsUseCase.ts new file mode 100644 index 0000000..b365128 --- /dev/null +++ b/src/domain/usecases/SearchUsersAndUserGroupsUseCase.ts @@ -0,0 +1,15 @@ +import { UserSharing } from "../entities/UserSharing"; +import { FutureData } from "../generic/Future"; +import { UserSharingRepository } from "../repositories/UserSharingRepository"; + +export class SearchUsersAndUserGroupsUseCase { + constructor( + private options: { + userSharingRepository: UserSharingRepository; + } + ) {} + + public execute(name: string): FutureData { + return this.options.userSharingRepository.get(name); + } +} diff --git a/src/domain/usecases/helpers/UCDataSetConfiguration.ts b/src/domain/usecases/helpers/UCDataSetConfiguration.ts new file mode 100644 index 0000000..e033f34 --- /dev/null +++ b/src/domain/usecases/helpers/UCDataSetConfiguration.ts @@ -0,0 +1,56 @@ +import _ from "lodash"; +import { DataSet } from "../../common/entities/DataSet"; +import { DataSetRepository } from "../../common/repositories/DataSetRepository"; +import { DataSetConfiguration } from "../../entities/DataSetConfiguration"; +import { Future, FutureData } from "../../generic/Future"; +import { DataSetConfigurationRepository } from "../../repositories/DataSetConfigurationRepository"; +import { UserRepository } from "../../repositories/UserRepository"; + +export class UCDataSetConfiguration { + constructor( + private options: { + dataSetConfigurationRepository: DataSetConfigurationRepository; + userRepository: UserRepository; + dataSetRepository: DataSetRepository; + } + ) {} + + getConfigurations(): FutureData { + return this.options.userRepository.getCurrent().flatMap(currentUser => { + const userGroupCodes = currentUser.userGroups.map(group => group.code); + + return this.options.dataSetConfigurationRepository.getAll().flatMap(dataSetConfigs => { + // If user is super admin, return all configurations + if (currentUser.isSuperAdmin) { + return Future.success(dataSetConfigs); + } + + const originDataSetCodes = dataSetConfigs.map(config => config.dataSetOriginalCode); + + return this.getDataSetsByCodes(originDataSetCodes).map(dataSets => { + const dsByCodes = _(dataSets) + .keyBy(ds => ds.code) + .value(); + + // Filter configurations based on user permissions + const filteredConfigurations = dataSetConfigs.filter(config => { + if (!dsByCodes[config.dataSetOriginalCode]) return false; + + return config.canUserPerformAction( + "read", + currentUser.username, + userGroupCodes, + currentUser.isSuperAdmin + ); + }); + + return filteredConfigurations; + }); + }); + }); + } + + private getDataSetsByCodes(codes: string[]): FutureData { + return this.options.dataSetRepository.getByCodes(codes); + } +} diff --git a/src/scripts/approve-mal-datavalues.ts b/src/scripts/approve-mal-datavalues.ts index f1658c8..7e2315e 100644 --- a/src/scripts/approve-mal-datavalues.ts +++ b/src/scripts/approve-mal-datavalues.ts @@ -12,7 +12,12 @@ import { promiseMap } from "../utils/promises"; import { DataDiffItemIdentifier } from "../domain/reports/mal-data-approval/entities/DataDiffItem"; import { ApproveMalDataValuesUseCase } from "../domain/reports/mal-data-approval/usecases/ApproveMalDataValuesUseCase"; import { writeFileSync } from "fs"; -import { AppSettingsD2Repository } from "../data/AppSettingsD2Repository"; +import { + DataSetWithConfigPermissions, + GetApprovalConfigurationsUseCase, +} from "../domain/usecases/GetApprovalConfigurationsUseCase"; +import { DataSetConfigurationD2Repository } from "../data/DataSetConfigurationD2Repository"; +import { UserD2Repository } from "../data/UserD2Repository"; const GLOBAL_OU = "WHO-HQ"; const DEFAULT_START_YEAR = 2001; @@ -36,6 +41,16 @@ export async function approveMalDataValues(options: ApprovalOptions): Promise config.dataSet.id === dataSet.id); + if (!config) { + console.error(`Approval configuration not found for dataSet ${dataSet.name} (${dataSet.id})`); + return; + } + if (malDataApprovalItems.length === 0) { console.debug(`No data values to approve in ${dataSet.name} dataset.`); return; @@ -54,7 +76,7 @@ export async function approveMalDataValues(options: ApprovalOptions): Promise { console.error("Error approving data values:", err); }) @@ -93,9 +115,9 @@ async function buildMalApprovalItems( dataSetRepository: DataSetD2Repository, dataSetId: Id, orgUnitId: Id, + dataSetConfigs: DataSetWithConfigPermissions[], yearOption?: string ): Promise { - const appSettings = await new AppSettingsD2Repository().get(); const periods = yearOption ? [yearOption] : _.range(DEFAULT_START_YEAR, DEFAULT_END_YEAR + 1).map(year => year.toString()); @@ -104,7 +126,7 @@ async function buildMalApprovalItems( const dataElementsWithValues = await new WmrDiffReport( dataValueRepository, dataSetRepository, - appSettings + dataSetConfigs ).getDiff( dataSetId, orgUnitId, diff --git a/src/scripts/check-data-differences.ts b/src/scripts/check-data-differences.ts index 611c947..5c8241b 100644 --- a/src/scripts/check-data-differences.ts +++ b/src/scripts/check-data-differences.ts @@ -9,7 +9,12 @@ import { Id } from "../domain/common/entities/Base"; import _ from "lodash"; import { promiseMap } from "../utils/promises"; import { WmrDiffReport } from "../domain/reports/WmrDiffReport"; -import { AppSettingsD2Repository } from "../data/AppSettingsD2Repository"; +import { UserD2Repository } from "../data/UserD2Repository"; +import { DataSetConfigurationD2Repository } from "../data/DataSetConfigurationD2Repository"; +import { + DataSetWithConfigPermissions, + GetApprovalConfigurationsUseCase, +} from "../domain/usecases/GetApprovalConfigurationsUseCase"; import { writeFileSync } from "fs"; const GLOBAL_OU = "WHO-HQ"; @@ -43,13 +48,26 @@ export async function checkMalDataValuesDiff(options: DataDifferencesOptions): P const dataValueRepository = new DataValuesD2Repository(api); const dataSetRepository = new DataSetD2Repository(api); + const userRepository = new UserD2Repository(api); + const dataSetConfigurationRepository = new DataSetConfigurationD2Repository(api); + + const getConfigUseCase = new GetApprovalConfigurationsUseCase({ + dataSetRepository, + dataSetConfigurationRepository, + userRepository, + }); + + const dataSetConfigs = await getConfigUseCase.execute().toPromise(); + const { dataSet, orgUnit } = await getMalWMRMetadata(api, dataSetCode, ouOption); console.debug(`dataSet original: ${dataSet.name} and orgUnit: ${orgUnit.name}`); + const dataElementsWithValues = await buildDataDifferenceItems({ dataValueRepository: dataValueRepository, dataSetRepository: dataSetRepository, dataSetId: dataSet.id, orgUnitId: orgUnit.id, + dataSetConfigs, yearOption: yearOption, dataSetApprovalName: dataSetApprovalName, }); @@ -71,13 +89,20 @@ async function buildDataDifferenceItems(options: { dataSetRepository: DataSetD2Repository; dataSetId: Id; orgUnitId: Id; - dataSetApprovalName: string; + dataSetConfigs: DataSetWithConfigPermissions[]; yearOption?: string; + dataSetApprovalName: string; }): Promise { - const { dataValueRepository, dataSetRepository, dataSetId, orgUnitId, yearOption, dataSetApprovalName } = options; + const { + dataValueRepository, + dataSetRepository, + dataSetId, + orgUnitId, + yearOption, + dataSetConfigs, + dataSetApprovalName, + } = options; const dataSetAPVD = await dataSetRepository.getByNameOrCode(dataSetApprovalName); - console.log(`dataSet approval: ${dataSetAPVD.name}`); - const appSettings = await new AppSettingsD2Repository().get(); // If not OU is provided, use the org. units assigned to the APVD data set const assignedOrgUnitIds = dataSetAPVD.organisationUnits.map(ou => ou.id); @@ -91,7 +116,7 @@ async function buildDataDifferenceItems(options: { const dataElementsWithValues = await new WmrDiffReport( dataValueRepository, dataSetRepository, - appSettings + dataSetConfigs ).getDiff( dataSetId, orgUnitId, diff --git a/src/scripts/generate-sqlviews.ts b/src/scripts/generate-sqlviews.ts new file mode 100644 index 0000000..a7eae7a --- /dev/null +++ b/src/scripts/generate-sqlviews.ts @@ -0,0 +1,296 @@ +import "dotenv-flow/config"; +import fs from "fs"; +import { D2Api, Id } from "../types/d2-api"; +import { ArgumentParser } from "argparse"; +import _ from "lodash"; +import { getUidFromSeed } from "../utils/uid"; + +const persistOptions = ["disk", "dhis"] as const; +type PersistOption = typeof persistOptions[number]; + +async function main() { + const parser = new ArgumentParser({ + description: `Approve data values in MAL WMR Form`, + }); + + parser.add_argument("-ds", "--dataSet", { + help: "DataSet code", + metavar: "dataSet", + }); + + parser.add_argument("-de-sub", "--dataElement-submission", { + help: "DataElement Submission Date Code", + metavar: "deSubmissionCode", + }); + + parser.add_argument("-de-apprv", "--dataElement-approval", { + help: "DataElement Approval Date Code", + metavar: "deApprovalCode", + }); + + parser.add_argument("-persist", "--persist", { + help: 'Save sqlViews to "disk" or "dhis"', + metavar: "persist", + }); + + try { + const args = parser.parse_args(); + const baseUrl = process.env.REACT_APP_DHIS2_BASE_URL || ""; + const authString = process.env.REACT_APP_DHIS2_AUTH || ""; + + const [username, password] = authString.split(":", 2); + if (!username || !password) throw new Error("Invalid DHIS2 authentication"); + + const persistOption = persistOptions.find(po => po === args.persist); + if (!persistOption) + throw new Error( + `Invalid persist option: '${args.persist}'. Valid options are: ${persistOptions.join(", ")}` + ); + + const api = new D2Api({ baseUrl, auth: { username, password } }); + + await generateSqlViews({ + api, + dataSetCode: args.dataSet, + deSubmissionCode: args.dataElement_submission, + deApprovalCode: args.dataElement_approval, + persistOption, + }); + } catch (err) { + console.error(err); + process.exit(1); + } +} + +async function getDataSetByCode(api: D2Api, code: string): Promise { + const response = await api.models.dataSets + .get({ + fields: { id: true, code: true, periodType: true }, + filter: { code: { eq: code } }, + paging: false, + }) + .getData(); + + const d2DataSet = response.objects[0]; + if (!d2DataSet) { + throw new Error(`DataSet with code '${code}' not found`); + } + + const currentPeriodType = periodTypes.find(pt => pt === d2DataSet.periodType); + if (!currentPeriodType) throw new Error(`Unsupported DataSet period type: '${d2DataSet.periodType}'`); + + return { code: d2DataSet.code, id: d2DataSet.id, periodType: currentPeriodType }; +} + +async function getDataElementsByCodes( + api: D2Api, + codes: string[] +): Promise<{ deSubmissionName: string; deApprovalName: string }> { + const response = await api.models.dataElements + .get({ + fields: { id: true, code: true, name: true }, + filter: { code: { in: codes } }, + paging: false, + }) + .getData(); + + const submissionDe = response.objects.find(de => de.code === codes[0]); + const approvalDe = response.objects.find(de => de.code === codes[1]); + + if (!submissionDe) { + throw new Error(`DataElement with code '${codes[0]}' not found`); + } + if (!approvalDe) { + throw new Error(`DataElement with code '${codes[1]}' not found`); + } + + return { deSubmissionName: submissionDe.name, deApprovalName: approvalDe.name }; +} + +async function generateSqlViews(args: { + api: D2Api; + dataSetCode: string; + deSubmissionCode: string; + deApprovalCode: string; + persistOption: PersistOption; +}): Promise { + const { api, dataSetCode, deSubmissionCode, deApprovalCode } = args; + const dataSet = await getDataSetByCode(api, dataSetCode); + const { deApprovalName, deSubmissionName } = await getDataElementsByCodes(api, [deSubmissionCode, deApprovalCode]); + const sqlViewData = generateSqlView({ dataSet, deApprovalName, deSubmissionName }); + + if (args.persistOption === "disk") { + fs.writeFileSync(`${sqlViewData.sqlDataSource.name}.sql`, sqlViewData.sqlDataSource.sqlQuery); + fs.writeFileSync(`${sqlViewData.sqlDataSourceOld.name}_old.sql`, sqlViewData.sqlDataSourceOld.sqlQuery); + fs.writeFileSync(`${sqlViewData.sqlViewDataValues.name}.sql`, sqlViewData.sqlViewDataValues.sqlQuery); + fs.writeFileSync(`${sqlViewData.sqlViewDataValuesOld.name}_old.sql`, sqlViewData.sqlViewDataValuesOld.sqlQuery); + console.debug("SQL views saved to disk"); + } else if (args.persistOption === "dhis") { + await saveSqlViews(api, sqlViewData); + } else { + throw new Error(`Unsupported persist option: ${args.persistOption}`); + } +} + +async function saveSqlViews(api: D2Api, sqlViewData: SqlTemplateViewInfo): Promise { + const sqlViews = [ + sqlViewData.sqlDataSource, + sqlViewData.sqlDataSourceOld, + sqlViewData.sqlViewDataValues, + sqlViewData.sqlViewDataValuesOld, + ]; + + const sqlViewIds = sqlViews.map(sv => sv.id); + + const existingSqlViews = await api.models.sqlViews + .get({ filter: { id: { in: sqlViewIds } }, paging: false, fields: { $owner: true } }) + .getData(); + + const sqlViewsToSave = sqlViews.map(sqlView => { + const d2SqlView = existingSqlViews.objects.find(d2SqlView => d2SqlView.id === sqlView.id); + + return { + ...(d2SqlView || {}), + id: sqlView.id, + name: sqlView.name, + sqlQuery: sqlView.sqlQuery, + type: sqlView.type, + cacheStrategy: "RESPECT_SYSTEM_SETTING" as const, + }; + }); + + const response = await api.metadata.post({ sqlViews: sqlViewsToSave }).getData(); + + console.debug(`SQL Views saved to DHIS2. Response: ${JSON.stringify(response.stats)}`); +} + +function generateSqlView(options: SqlTemplateOptions): SqlTemplateViewInfo { + const { dataSet, deApprovalName, deSubmissionName } = options; + + const isYearly = dataSet.periodType === "Yearly"; + + _.templateSettings = { + evaluate: /<%([\s\S]+?)%>/g, + interpolate: /<%=([\s\S]+?)%>/g, + escape: /<%-([\s\S]+?)%>/g, + }; + + const dvSql = isYearly + ? readSqlView("src/scripts/sql-templates/datavalues_yearly.sql") + : readSqlView("src/scripts/sql-templates/datavalues_monthly.sql"); + + const dvSqlOld = isYearly + ? readSqlView("src/scripts/sql-templates/datavalues_yearly_old.sql") + : readSqlView("src/scripts/sql-templates/datavalues_monthly.sql"); + + const dvOldLodashTemplate = _.template(dvSqlOld); + const dvLodashTemplate = _.template(dvSql); + + const dvViewName = dataSet.code.replaceAll("-", "_").replaceAll("_", "").replaceAll(" ", "_").toLowerCase(); + + const dvVariables: TemplateVariables = { + periodTypeColumn: dataSet.periodType === "Yearly" ? "yearly" : "monthly", + periodTypeName: dataSet.periodType, + submissionDataElementName: deSubmissionName, + approvalDataElementName: deApprovalName, + dataSetId: dataSet.id, + viewTableName: `dv${dvViewName}`, + months: "11", + sqlViewName: `dv${dvViewName}`, + }; + + const dvVariablesOld: TemplateVariables = { + periodTypeColumn: dataSet.periodType === "Yearly" ? "yearly" : "monthly", + periodTypeName: dataSet.periodType, + submissionDataElementName: deSubmissionName, + approvalDataElementName: deApprovalName, + dataSetId: dataSet.id, + viewTableName: `dv${dvViewName}oldperiods`, + months: "24", + sqlViewName: `dv${dvViewName}oldperiods`, + }; + + const dvCompiled = dvLodashTemplate(dvVariables); + const dvOldCompiled = dvOldLodashTemplate(dvVariablesOld); + + const dataSourceSql = readSqlView("src/scripts/sql-templates/datasource.sql"); + const dataSourceOldSql = readSqlView("src/scripts/sql-templates/datasource.sql"); + + const dataSourceTemplate = _.template(dataSourceSql); + const dataSourceOldTemplate = _.template(dataSourceOldSql); + + const dataSourceVariables = { ...dvVariables, sqlViewName: `${dataSet.code} Data Approval` }; + const dataSourceOldVariables = { ...dvVariablesOld, sqlViewName: `${dataSet.code} Data Approval Old Periods` }; + + const dsCompiled = dataSourceTemplate(dataSourceVariables); + const dsOldCompiled = dataSourceOldTemplate(dataSourceOldVariables); + + return { + sqlViewDataValues: { + id: getUidFromSeed(dvVariables.sqlViewName), + sqlQuery: dvCompiled, + name: dvVariables.sqlViewName, + type: "MATERIALIZED_VIEW", + }, + sqlViewDataValuesOld: { + id: getUidFromSeed(dvVariablesOld.sqlViewName), + sqlQuery: dvOldCompiled, + name: dvVariablesOld.sqlViewName, + type: "MATERIALIZED_VIEW", + }, + sqlDataSource: { + id: getUidFromSeed(dataSourceVariables.sqlViewName), + sqlQuery: dsCompiled, + name: dataSourceVariables.sqlViewName, + type: "QUERY", + }, + sqlDataSourceOld: { + id: getUidFromSeed(dataSourceOldVariables.sqlViewName), + sqlQuery: dsOldCompiled, + name: dataSourceOldVariables.sqlViewName, + type: "QUERY", + }, + }; +} + +function readSqlView(path: string) { + const sqlView = fs.readFileSync(path, "utf-8"); + return sqlView; +} + +main(); + +type TemplateVariables = { + periodTypeColumn: "yearly" | "monthly"; + periodTypeName: PeriodType; + submissionDataElementName: string; + approvalDataElementName: string; + dataSetId: string; + viewTableName: string; + months: string; + sqlViewName: string; +}; + +const periodTypes = ["Yearly", "Monthly"] as const; +type PeriodType = typeof periodTypes[number]; +type DataSetInfo = { code: string; id: Id; periodType: "Yearly" | "Monthly" }; + +type SqlTemplateOptions = { + dataSet: DataSetInfo; + deApprovalName: string; + deSubmissionName: string; +}; + +type SqlTemplateViewInfo = { + sqlViewDataValues: D2SqlViewAttrs; + sqlViewDataValuesOld: D2SqlViewAttrs; + sqlDataSource: D2SqlViewAttrs; + sqlDataSourceOld: D2SqlViewAttrs; +}; + +type D2SqlViewAttrs = { + id: string; + sqlQuery: string; + name: string; + type: "MATERIALIZED_VIEW" | "QUERY"; +}; diff --git a/src/scripts/sql-templates/datasource.sql b/src/scripts/sql-templates/datasource.sql new file mode 100644 index 0000000..fa9005d --- /dev/null +++ b/src/scripts/sql-templates/datasource.sql @@ -0,0 +1,102 @@ +SELECT dataset.name AS dataset, + dataset.uid AS datasetuid, + organisationunit.uid AS orgunituid, + organisationunit.name AS orgunit, + _periodstructure.<%= periodTypeColumn %> AS period, + categoryoptioncombo.name AS attribute, + dataapprovalworkflow.uid AS approvalworkflowuid, + dataapprovalworkflow.name AS approvalworkflow, + entries.attributeoptioncomboid AS att, + entries.lastupdated AS lastupdatedvalue, + entries.lastdatesubmited as lastdateofsubmission, + entries.lastdateofapproval as lastdateofapproval, + entries.diff as diff, + completedatasetregistration.completed IS NOT NULL AS completed, + dataapproval.accepted IS NOT NULL AS validated + FROM ((SELECT distinct datavalue.periodid, + datavalue.sourceid AS organisationunitid, + datavalue.attributeoptioncomboid, + (select datasetid from dataset where uid ~ ('^' || replace('${dataSets}', '-', '|') || '$')) as datasetid, + (select workflowid from dataset where uid ~ ('^' || replace('${dataSets}', '-', '|') || '$') )as workflowid, + (select max(dv1.lastupdated) from datavalue dv1 where dv1.dataelementid + not in (select dataelementid from dataelement where name='<%= submissionDataElementName %>') + and dataelementid in (select dataelementid from datasetelement where datasetid in + (select datasetid from dataset where uid ~ ('^' || replace('${dataSets}', '-', '|') || '$') )) + and dv1.sourceid = datavalue.sourceid and dv1.periodid=datavalue.periodid) as lastupdated, + + (select sum(diff.count) from ( select count(*) as count from datavalue dva where + dva.dataelementid in (select dataelementid from dataelement where dataelementid in + (select dataelementid from datasetelement where datasetid in + (select datasetid from dataset where uid ~ ('^' || replace('${dataSets}', '-', '|') || '$') ))) + and + dva.lastupdated::timestamp without time zone > +(select + CASE WHEN ( select dv2.lastupdated from datavalue dv2 where + dv2.dataelementid in (select de.dataelementid from dataelement de where de.name like '<%= approvalDataElementName %>') + and dv2.sourceid=datavalue.sourceid + and dv2.periodid=datavalue.periodid + and dv2.attributeoptioncomboid= datavalue.attributeoptioncomboid + and dv2.deleted = false ) IS NULL + THEN '1995-12-11'::timestamp without time zone + ELSE ( select dv2.lastupdated from datavalue dv2 where + dv2.dataelementid in (select de.dataelementid from dataelement de where de.name like '<%= approvalDataElementName %>') + and dv2.sourceid=datavalue.sourceid + and dv2.periodid=datavalue.periodid + and dv2.attributeoptioncomboid= datavalue.attributeoptioncomboid + and dv2.deleted = false )::timestamp without time zone + END) + + + and dva.sourceid=datavalue.sourceid + and dva.periodid=datavalue.periodid + and dva.attributeoptioncomboid= datavalue.attributeoptioncomboid + and dva.deleted = false + GROUP BY dva.periodid, dva.sourceid, dva.attributeoptioncomboid, dva.dataelementid + ) as diff ) as diff , + + (select dv1.value from datavalue dv1 where dv1.dataelementid = (select dataelementid from dataelement where name='<%= submissionDataElementName %>') and dv1.sourceid = datavalue.sourceid and dv1.periodid=datavalue.periodid) as lastdatesubmited, + (select dv1.value from datavalue dv1 where dv1.dataelementid = (select dataelementid from dataelement where name='<%= approvalDataElementName %>') and dv1.sourceid = datavalue.sourceid and dv1.periodid=datavalue.periodid) as lastdateofapproval + FROM _view_<%= viewTableName %> datavalue + /** TODO: Filter by DEs, remove totals **/ +WHERE periodid in (select periodid from period where periodtypeid = (select periodtypeid from periodtype where name='<%= periodTypeName %>')) + and periodid in (select periodid from _periodstructure where _periodstructure.iso ~ ('^' || replace('${periods}', '-', '|') || '$')) + AND sourceid in ( select dss.sourceid from datasetsource dss where dss.datasetid = (select datasetid from dataset where uid ~ ('^' || replace('${dataSets}', '-', '|') || '$') )) + and datavalue.dataelementid not in (select dataelementid from dataelement where name='<%= submissionDataElementName %>') + + + GROUP BY datavalue.periodid, datavalue.sourceid, datavalue.attributeoptioncomboid , datavalue.lastupdated, datavalue.dataelementid + having max(datavalue.lastupdated) = datavalue.lastupdated) AS entries + INNER JOIN _periodstructure USING (periodid) + INNER JOIN organisationunit USING (organisationunitid) + INNER JOIN _orgunitstructure USING (organisationunitid) + INNER JOIN dataapprovalworkflow USING (workflowid) + INNER JOIN dataapprovallevel ON (dataapprovallevel.orgunitlevel = _orgunitstructure.level) + INNER JOIN dataset USING (datasetid) + INNER JOIN categoryoptioncombo ON (categoryoptioncombo.categoryoptioncomboid = entries.attributeoptioncomboid) + LEFT JOIN completedatasetregistration ON ((completedatasetregistration.datasetid = entries.datasetid) AND + (completedatasetregistration.periodid = entries.periodid) AND + (completedatasetregistration.sourceid = entries.organisationunitid) AND + (completedatasetregistration.attributeoptioncomboid = + entries.attributeoptioncomboid)) + LEFT JOIN dataapproval ON ((dataapproval.workflowid = dataset.workflowid) AND + (dataapproval.organisationunitid = entries.organisationunitid) AND + (dataapproval.periodid = entries.periodid) AND + (dataapproval.attributeoptioncomboid = entries.attributeoptioncomboid) AND + (dataapproval.dataapprovallevelid = dataapprovallevel.dataapprovallevelid))) +WHERE organisationunit.path ~ (replace('${orgUnitRoot}', '-', '|')) + AND organisationunit.uid ~ ('^' || replace('${orgUnits}', '-', '|') || '$') + AND _periodstructure.monthly ~ ('^' || replace('${periods}', '-', '|') || '$') + AND (completedatasetregistration.completed IS NOT NULL)::text ~ ('^' || replace('${completed}', '-', '|') || '$') + AND (dataapproval.accepted IS NOT NULL)::text ~ ('^' || replace('${approved}', '-', '|') || '$') + ORDER BY + ${orderByColumn} ${orderByDirection}, + lastupdatedvalue desc, + orgunit ASC, + period DESC, + dataset ASC, + attribute ASC, + completed ASC, + validated ASC, + lastupdatedvalue DESC, + lastdateofsubmission ASC, + lastdateofapproval ASC diff --git a/src/scripts/sql-templates/datavalues_monthly.sql b/src/scripts/sql-templates/datavalues_monthly.sql new file mode 100644 index 0000000..20809ae --- /dev/null +++ b/src/scripts/sql-templates/datavalues_monthly.sql @@ -0,0 +1,105 @@ +select + baserows.sourceid, + baserows.periodid, + baserows.attributeoptioncomboid, + ( + select dv.dataelementid + from datavalue dv + where dv.sourceid = baserows.sourceid + and dv.periodid = baserows.periodid + and dv.attributeoptioncomboid = baserows.attributeoptioncomboid + and dv.dataelementid not in ( + select de.dataelementid + from dataelement de + where de.name in ( + '<%= submissionDataElementName %>', + '<%= approvalDataElementName %>' + ) + ) + order by dv.lastupdated desc + limit 1 + ) as dataelementid, + ( + select dv.lastupdated + from datavalue dv + where dv.sourceid = baserows.sourceid + and dv.periodid = baserows.periodid + and dv.attributeoptioncomboid = baserows.attributeoptioncomboid + and dv.dataelementid not in ( + select de.dataelementid + from dataelement de + where de.name in ( + '<%= submissionDataElementName %>', + '<%= approvalDataElementName %>' + ) + ) + order by dv.lastupdated desc + limit 1 + ) as lastupdated +from ( + select distinct on (dv.sourceid, dv.periodid, dv.attributeoptioncomboid) + dv.sourceid, + dv.periodid, + dv.attributeoptioncomboid + from datavalue dv + where dv.periodid in ( + select dv2.periodid + from datavalue dv2 + where dv2.sourceid in ( + select ds.sourceid + from datasetsource ds + where ds.datasetid = ( + select d.datasetid + from dataset d + where d.uid = '<%= dataSetId %>' + ) + ) + and dv2.dataelementid in ( + select dse.dataelementid + from datasetelement dse + where dse.datasetid = ( + select d.datasetid + from dataset d + where d.uid = '<%= dataSetId %>' + ) + ) + and dv2.dataelementid not in ( + select de.dataelementid + from dataelement de + where de.name in ( + '<%= submissionDataElementName %>', + '<%= approvalDataElementName %>' + ) + ) + and dv2.periodid in ( + select p.periodid + from period p + join periodtype pt on pt.periodtypeid = p.periodtypeid + where pt.name = 'Monthly' + and p.startdate >= date_trunc('month', current_date) - interval '<%= months %> months' + and p.startdate < date_trunc('month', current_date) + interval '1 month' + ) + ) + and dv.dataelementid in ( + select dv3.dataelementid + from datavalue dv3 + where dv3.dataelementid in ( + select dse2.dataelementid + from datasetelement dse2 + where dse2.datasetid = ( + select d.datasetid + from dataset d + where d.uid = '<%= dataSetId %>' + ) + ) + ) + and dv.sourceid in ( + select ds2.sourceid + from datasetsource ds2 + where ds2.datasetid = ( + select d.datasetid + from dataset d + where d.uid = '<%= dataSetId %>' + ) + ) +) as baserows; diff --git a/src/scripts/sql-templates/datavalues_yearly.sql b/src/scripts/sql-templates/datavalues_yearly.sql new file mode 100644 index 0000000..b2ea576 --- /dev/null +++ b/src/scripts/sql-templates/datavalues_yearly.sql @@ -0,0 +1,46 @@ +select baserows.sourceid, baserows.periodid, baserows.attributeoptioncomboid, + ( + SELECT dataelementid + FROM datavalue + WHERE sourceid = baserows.sourceid + AND periodid = baserows.periodid + AND attributeoptioncomboid = baserows.attributeoptioncomboid + AND dataelementid NOT IN ( + SELECT de.dataelementid + FROM dataelement de + WHERE de.name IN ('<%= submissionDataElementName %>', '<%= approvalDataElementName %>') + ) + ORDER BY lastupdated DESC + LIMIT 1 + ) AS dataelementid, + ( + SELECT lastupdated + FROM datavalue + WHERE sourceid = baserows.sourceid + AND periodid = baserows.periodid + AND attributeoptioncomboid = baserows.attributeoptioncomboid + AND dataelementid NOT IN ( + SELECT de.dataelementid + FROM dataelement de + WHERE de.name IN ('<%= submissionDataElementName %>', '<%= approvalDataElementName %>') + ) + ORDER BY lastupdated DESC + LIMIT 1 + ) AS lastupdated +from (select + distinct on(datavalue.sourceid, datavalue.periodid, datavalue.attributeoptioncomboid) datavalue.sourceid, datavalue.periodid, datavalue.attributeoptioncomboid +from datavalue where periodid in (select periodid from datavalue where sourceid in (select sourceid from datasetsource where datasetid = (select datasetid from dataset where uid = '<%= dataSetId %>' ) ) + and dataelementid in (select dataelementid from datasetelement where datasetid = (select datasetid from dataset where uid = '<%= dataSetId %>' ) ) + AND dataelementid NOT IN ( + SELECT de.dataelementid + FROM dataelement de + WHERE de.name IN ('<%= submissionDataElementName %>', '<%= approvalDataElementName %>') + ) + and periodid in ( select periodid from period where periodtypeid = ( select periodtypeid from periodtype where name = 'Yearly' ) ) + and periodid in ( select periodid from period where SUBSTRING(CAST(period.startdate AS varchar), 1, 4) in ( ((date_part('year', current_date) - 5)::text), + ((date_part('year', current_date) - 4)::text), + ((date_part('year', current_date) - 3)::text), + ((date_part('year', current_date) - 2)::text), + ((date_part('year', current_date) - 1)::text) ) ) ) + and dataelementid in (select dataelementid from datavalue where dataelementid in (select dataelementid from datasetelement where datasetid = (select datasetid from dataset where uid = '<%= dataSetId %>' ) )) + and sourceid in (select sourceid from datasetsource where datasetid = (select datasetid from dataset where uid = '<%= dataSetId %>' ) )) as baserows diff --git a/src/scripts/sql-templates/datavalues_yearly_old.sql b/src/scripts/sql-templates/datavalues_yearly_old.sql new file mode 100644 index 0000000..279735d --- /dev/null +++ b/src/scripts/sql-templates/datavalues_yearly_old.sql @@ -0,0 +1,55 @@ +SELECT + baserows.sourceid, + baserows.periodid, + baserows.attributeoptioncomboid, + ( + SELECT dv.dataelementid + FROM datavalue dv + WHERE dv.sourceid = baserows.sourceid + AND dv.periodid = baserows.periodid + AND dv.attributeoptioncomboid = baserows.attributeoptioncomboid + AND dv.dataelementid NOT IN ( + SELECT de.dataelementid + FROM dataelement de + WHERE de.name IN ('<%= submissionDataElementName %>', '<%= approvalDataElementName %>') + ) + ORDER BY dv.lastupdated DESC + LIMIT 1 + ) AS dataelementid, + ( + SELECT dv.lastupdated + FROM datavalue dv + WHERE dv.sourceid = baserows.sourceid + AND dv.periodid = baserows.periodid + AND dv.attributeoptioncomboid = baserows.attributeoptioncomboid + + AND dv.dataelementid NOT IN ( + SELECT de.dataelementid + FROM dataelement de + WHERE de.name IN ('<%= submissionDataElementName %>', '<%= approvalDataElementName %>') + ) + ORDER BY dv.lastupdated DESC + LIMIT 1 + ) AS lastupdated +FROM ( + SELECT DISTINCT ON (dv.sourceid, dv.periodid, dv.attributeoptioncomboid) + dv.sourceid, dv.periodid, dv.attributeoptioncomboid + FROM datavalue dv + + INNER JOIN datasetelement dse ON dv.dataelementid = dse.dataelementid + + INNER JOIN datasetsource dss ON dv.sourceid = dss.sourceid + + INNER JOIN period p ON dv.periodid = p.periodid + + INNER JOIN dataelement de ON dv.dataelementid = de.dataelementid + WHERE + dss.datasetid = (SELECT datasetid FROM dataset WHERE uid = '<%= dataSetId %>') + AND dse.datasetid = (SELECT datasetid FROM dataset WHERE uid = '<%= dataSetId %>') + + AND p.periodtypeid = (SELECT periodtypeid FROM periodtype WHERE name = 'Yearly') + + AND CAST(SUBSTRING(CAST(p.startdate AS varchar), 1, 4) AS integer) BETWEEN 2000 AND 2019 + + AND de.name NOT IN ('<%= submissionDataElementName %>', '<%= approvalDataElementName %>') +) AS baserows; diff --git a/src/utils/uid.ts b/src/utils/uid.ts index ba82444..c1db4ee 100644 --- a/src/utils/uid.ts +++ b/src/utils/uid.ts @@ -1,9 +1,16 @@ +// @ts-ignore +import MD5 from "md5.js"; + // DHIS2 UID :: /^[a-zA-Z][a-zA-Z0-9]{10}$/ const asciiLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; const asciiNumbers = "0123456789"; const asciiLettersAndNumbers = asciiLetters + asciiNumbers; const uidSize = 10; +const range = Array.from(new Array(uidSize).keys()); +const uidStructure = [asciiLetters, ...range.map(() => asciiLettersAndNumbers)]; +const maxHashValue = uidStructure.map(cs => cs.length).reduce((acc, n) => acc * n, 1); + function randomWithMax(max: number) { return Math.floor(Math.random() * max); } @@ -18,3 +25,22 @@ export function generateUid(): string { return randomChars; } + +export function getUidFromSeed(seed: string): string { + const md5hash: string = new MD5().update(seed).digest("hex"); + const nHashChars = Math.ceil(Math.log(maxHashValue) / Math.log(16)); + const hashInteger = parseInt(md5hash.slice(0, nHashChars), 16); + const result = uidStructure.reduce( + (acc, chars) => { + const { n, uid } = acc; + const nChars = chars.length; + const quotient = Math.floor(n / nChars); + const remainder = n % nChars; + const uidChar = chars[remainder]; + return { n: quotient, uid: uid + uidChar }; + }, + { n: hashInteger, uid: "" } + ); + + return result.uid; +} diff --git a/src/webapp/components/app/App.tsx b/src/webapp/components/app/App.tsx index dc2a243..6a97037 100644 --- a/src/webapp/components/app/App.tsx +++ b/src/webapp/components/app/App.tsx @@ -57,7 +57,9 @@ const App = ({ api, d2 }: { api: D2Api; d2: D2 }) => {
- + {appContext && ( + + )}
diff --git a/src/webapp/components/dataset-config/DataSetConfigForm.tsx b/src/webapp/components/dataset-config/DataSetConfigForm.tsx new file mode 100644 index 0000000..c30e99b --- /dev/null +++ b/src/webapp/components/dataset-config/DataSetConfigForm.tsx @@ -0,0 +1,220 @@ +import { Button, Checkbox, createStyles, Grid, LinearProgress, makeStyles, Paper, Theme } from "@material-ui/core"; +import React from "react"; +import { + DataSetConfiguration, + DataSetConfigurationAction, + dataSetConfigurationActions, +} from "../../../domain/entities/DataSetConfiguration"; +import { Maybe } from "../../../types/utils"; +import i18n from "../../../locales"; +import { PermissionsSharing } from "../share/PermissionsSharing"; +import _ from "../../../domain/generic/Collection"; +import { EntitySelector, TableEntity } from "../entity-selector/EntitySelector"; + +type DataSetConfigFormProps = { + configuration: DataSetConfiguration; + onChange: (configuration: DataSetConfiguration) => void; + onSave: (configuration: DataSetConfiguration) => void; + onError: (message: string) => void; + onCancel: () => void; + isLoading?: boolean; +}; + +export const DataSetConfigForm: React.FC = props => { + const { configuration, isLoading, onCancel, onChange, onSave, onError } = props; + + const [selectedPermission, setSelectedPermission] = React.useState(); + + const classes = useStyles(); + + const selectPermission = (action: Maybe) => { + setSelectedPermission(action); + }; + + const updateDataSet = (code: string, type: "original" | "destination") => { + const newConfiguration = + type === "original" + ? configuration.updateDataSetOriginal(code) + : configuration.updateDataSetDestination(code); + onChange(newConfiguration); + }; + + const updateDataElement = (code: string, type: "submit" | "approval") => { + const newConfiguration = + type === "submit" + ? configuration.updateSubmissionDateDataElement(code) + : configuration.updateApprovalDateDataElement(code); + onChange(newConfiguration); + }; + + const updateDataSource = (entity: TableEntity) => { + const newConfiguration = configuration.updateDataSourceId(entity.id); + onChange(newConfiguration); + }; + + const updateOldDataSource = (entity: TableEntity) => { + const newConfiguration = configuration.updateOldDataSourceId(entity.id); + onChange(newConfiguration); + }; + + const handleSubmit = React.useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + const errors = DataSetConfiguration.validate(configuration); + if (errors.length > 0) { + onError(i18n.t("Please complete all required fields.")); + } else { + onSave(configuration); + } + }, + [configuration, onSave, onError] + ); + + return ( + + {isLoading && } +
+ + + updateDataSet(entity.code, "original")} + onlyWithCode + /> + + + updateDataSet(entity.code, "destination")} + onlyWithCode + /> + + + updateDataElement(entity.code, "submit")} + onlyWithCode + /> + + + updateDataElement(entity.code, "approval")} + onlyWithCode + /> + + + + + + + + + { + onChange(configuration.updateSubmitAndComplete(e.target.checked)); + }} + /> + {i18n.t("Submit also approves the dataSet")} + + + { + onChange(configuration.updateRevokeAndIncomplete(e.target.checked)); + }} + /> + {i18n.t("Revoke also marks dataSet as incomplete")} + +
+ {dataSetConfigurationActions.map(action => { + const { userGroups, users } = configuration.permissions[action]; + + return ( +
+ + selectPermission(undefined)} + onChange={params => { + const updatedPermissions = configuration.updatePermissions({ + action: action, + userGroupCodes: _(params.userGroupCodes).uniq().value(), + usernames: _(params.usernames).uniq().value(), + }); + onChange(updatedPermissions); + }} + /> +
+ ); + })} +
+ + + + + +
+
+
+ ); +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + btnContainer: { + paddingInlineStart: theme.spacing(1), + gap: theme.spacing(1), + display: "flex", + }, + container: { + rowGap: theme.spacing(2), + }, + form: { + padding: theme.spacing(2), + }, + permissionContainer: { + display: "grid", + gap: theme.spacing(2), + gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", + width: "100%", + }, + permissionItem: { + display: "flex", + }, + }) +); diff --git a/src/webapp/components/dataset-config/DataSetConfigTable.tsx b/src/webapp/components/dataset-config/DataSetConfigTable.tsx new file mode 100644 index 0000000..648ef01 --- /dev/null +++ b/src/webapp/components/dataset-config/DataSetConfigTable.tsx @@ -0,0 +1,83 @@ +import { ObjectsTable, TableConfig } from "@eyeseetea/d2-ui-components"; +import EditIcon from "@material-ui/icons/EditOutlined"; +import DeleteIcon from "@material-ui/icons/DeleteForever"; +import { DataSetConfiguration } from "../../../domain/entities/DataSetConfiguration"; +import i18n from "../../../locales"; + +type DataSetConfigTableProps = { + data: DataSetConfiguration[]; + isSuperAdmin: boolean; + onAction: (params: { action: string; item: DataSetConfiguration }) => void; + onAdd?: () => void; + loading?: boolean; +}; + +export const DataSetConfigTable: React.FC = props => { + const { data, isSuperAdmin, onAction, onAdd, loading } = props; + + const tableConfig: TableConfig = { + columns: [ + { + name: "dataSetOriginalCode", + text: i18n.t("DataSet Code"), + sortable: false, + }, + { + name: "dataSetDestinationCode", + text: i18n.t("DataSet Approval Code"), + sortable: false, + }, + { + name: "submissionDateCode", + text: i18n.t("DataElement Submission Date"), + sortable: false, + }, + { + name: "approvalDateCode", + text: i18n.t("DataElement Approval Date"), + sortable: false, + }, + ], + actions: [ + { + name: "edit", + text: i18n.t("Edit"), + icon: , + isActive: () => isSuperAdmin, + onClick: item => { + const action = getItemByAction({ data, action: "edit", item }); + if (action) onAction(action); + }, + multiple: false, + }, + { + name: "delete", + text: i18n.t("Delete"), + icon: , + isActive: () => isSuperAdmin, + onClick: item => { + const action = getItemByAction({ data, action: "delete", item }); + if (action) onAction(action); + }, + multiple: false, + }, + ], + onActionButtonClick: isSuperAdmin + ? () => { + if (onAdd) onAdd(); + } + : undefined, + initialSorting: { field: "id", order: "asc" }, + paginationOptions: { pageSizeInitialValue: 50, pageSizeOptions: [50] }, + }; + + return ; +}; + +function getItemByAction(props: { data: DataSetConfiguration[]; action: string; item: string[] }) { + const { data, action, item } = props; + const firstId = item[0]; + const dsConfig = data.find(config => config.id === firstId); + if (!dsConfig) return; + return { action: action, item: dsConfig }; +} diff --git a/src/webapp/components/entity-selector/EntitySelector.tsx b/src/webapp/components/entity-selector/EntitySelector.tsx new file mode 100644 index 0000000..90f5f0e --- /dev/null +++ b/src/webapp/components/entity-selector/EntitySelector.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import styled from "styled-components"; +import { Button, TextField, Typography } from "@material-ui/core"; +import { + ConfirmationDialog, + ObjectsTable, + TableConfig, + TablePagination, + useObjectsTable, +} from "@eyeseetea/d2-ui-components"; +import { useAppContext } from "../../contexts/app-context"; +import i18n from "../../../locales"; +import { MetadataEntityType } from "../../../domain/repositories/MetadataEntityRepository"; + +type EntitySelectorProps = { + label: string; + value: string; + type: MetadataEntityType; + onChange: (entity: TableEntity) => void; + onlyWithCode?: boolean; +}; + +export const EntitySelector = (props: EntitySelectorProps) => { + const { label, onChange, value, type, onlyWithCode } = props; + const [showModal, setShowModal] = React.useState(false); + const [selectedEntity, setSelectedEntity] = React.useState(); + + const openTable = React.useCallback(() => { + setShowModal(true); + }, []); + + const selectEntity = React.useCallback( + (entity: TableEntity) => { + onChange(entity); + setShowModal(false); + setSelectedEntity(entity); + }, + [onChange] + ); + + return ( +
+ + setShowModal(false)} + maxWidth="lg" + open={showModal} + onClose={() => setShowModal(false)} + > + + {i18n.t("Click on the ID to select the row")} + + + +
+ ); +}; + +export function TableSelector(props: TableSelectorProps) { + const { type, onChange, onlyWithCode } = props; + const [loading, setLoading] = React.useState(false); + const { compositionRoot } = useAppContext(); + + const getRows = React.useCallback( + (search: string, pagination: TablePagination) => { + setLoading(true); + return compositionRoot.metadata.getBy + .execute({ + type, + page: pagination.page, + pageSize: pagination.pageSize, + onlyWithCode: onlyWithCode ?? false, + search: search, + }) + .toPromise() + .then(result => { + setLoading(false); + return result; + }) + .catch(() => { + setLoading(false); + return { objects: [], pager: { page: 1, pageCount: 1, pageSize: pagination.pageSize, total: 0 } }; + }); + }, + [compositionRoot.metadata.getBy, type, onlyWithCode] + ); + + const onSelectRow = React.useCallback( + row => { + onChange(row); + }, + [onChange] + ); + + const config: TableConfig = React.useMemo(() => { + return { + actions: [], + columns: [ + { + name: "id", + text: "ID", + sortable: false, + getValue: row => { + return ( + + ); + }, + }, + { name: "name", text: "Name", sortable: false }, + { name: "code", text: "Code", sortable: false }, + ], + initialSorting: { field: "id", order: "asc" }, + paginationOptions: { pageSizeInitialValue: 25, pageSizeOptions: [25, 50, 100] }, + }; + }, [onSelectRow]); + + const tableConfig = useObjectsTable(config, getRows); + + return ; +} + +const TableSelectorContainer = styled.div` + padding: 1em; +`; + +type TableSelectorProps = { + onlyWithCode?: boolean; + type: MetadataEntityType; + onChange: (entity: TableEntity) => void; +}; + +export type TableEntity = { + id: string; + name: string; + code: string; +}; diff --git a/src/webapp/components/share/PermissionsSharing.tsx b/src/webapp/components/share/PermissionsSharing.tsx new file mode 100644 index 0000000..7a882b9 --- /dev/null +++ b/src/webapp/components/share/PermissionsSharing.tsx @@ -0,0 +1,121 @@ +import React from "react"; +import { Sharing } from "@eyeseetea/d2-ui-components"; +import { Button, Dialog, DialogActions, DialogContent } from "@material-ui/core"; +import { User } from "../../../domain/entities/User"; +import { useAppContext } from "../../contexts/app-context"; +import i18n from "../../../locales"; + +type PermissionsSharingProps = { + usernames: string[]; + userGroupCodes: string[]; + onChange: (params: { usernames: string[]; userGroupCodes: string[] }) => void; + onClose: () => void; + visible: boolean; + title?: string; +}; + +export const PermissionsSharing: React.FC = props => { + const { compositionRoot } = useAppContext(); + const { title, onClose, usernames, userGroupCodes, onChange, visible } = props; + const [usersWithAccess, setUsersWithAccess] = React.useState([]); + const [groupsWithAccess, setGroupsWithAccess] = React.useState([]); + + React.useEffect(() => { + compositionRoot.users.getByUsernames.execute(usernames).run((users: User[]) => { + setUsersWithAccess( + usernames.length > 0 + ? users.map(user => ({ + id: user.username, + name: user.name, + username: user.username, + })) + : [] + ); + }, console.error); + }, [compositionRoot, usernames]); + + React.useEffect(() => { + return compositionRoot.userGroups.getByCodes.execute(userGroupCodes).run(userGroups => { + setGroupsWithAccess( + userGroups.map(group => ({ + id: group.code, + code: group.code, + name: group.name, + })) + ); + }, console.error); + }, [compositionRoot, userGroupCodes]); + + const closeModal = React.useCallback(() => { + onClose(); + }, [onClose]); + + return ( + + + ({ + access: "", + displayName: user.name, + id: user.username, + })), + userGroupAccesses: groupsWithAccess.map(group => ({ + access: "", + displayName: group.name, + id: group.code, + })), + }, + }} + onChange={args => { + const newUsernames = args.userAccesses ? args.userAccesses.map(user => user.id) : usernames; + const newUserGroupCodes = args.userGroupAccesses + ? args.userGroupAccesses.map(group => group.id) + : userGroupCodes; + + onChange({ usernames: newUsernames, userGroupCodes: newUserGroupCodes }); + + return Promise.resolve(); + }} + onSearch={query => { + return compositionRoot.sharing.search + .execute(query) + .toPromise() + .then(results => { + return { + users: results.users.map(user => ({ + id: user.username, + displayName: user.name, + })), + userGroups: results.userGroups + .filter(userGroup => Boolean(userGroup.code)) + .map(userGroup => ({ + id: userGroup.code, + displayName: userGroup.name, + })), + }; + }); + }} + /> + + + + + + ); +}; + +type UserAccess = { id: string; name: string; username: string }; +type UserGroupAccess = { id: string; code: string; name: string }; diff --git a/src/webapp/hooks/UseAutocompleteCompute.tsx b/src/webapp/hooks/UseAutocompleteCompute.tsx deleted file mode 100644 index 3891bb9..0000000 --- a/src/webapp/hooks/UseAutocompleteCompute.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; -import React from "react"; -import { AutoCompleteComputeSettings } from "../../domain/reports/nhwa-auto-complete-compute/entities/AutoCompleteComputeSettings"; -import { useAppContext } from "../contexts/app-context"; - -type UseSettingsProps = { settingKey: string }; - -export function useSettings(props: UseSettingsProps) { - const { compositionRoot } = useAppContext(); - const loading = useLoading(); - const snackbar = useSnackbar(); - const [settings, setSettings] = React.useState(); - - React.useEffect(() => { - compositionRoot.nhwa.getAutoCompleteComputeSettings - .execute({ settingsKey: props.settingKey }) - .then(result => { - setSettings(result); - }) - .catch(error => { - snackbar.error(error.message); - }); - }, [props.settingKey, compositionRoot, loading, snackbar]); - - return { settings }; -} diff --git a/src/webapp/pages/AddDataSetSettingsPage.tsx b/src/webapp/pages/AddDataSetSettingsPage.tsx new file mode 100644 index 0000000..e4d64c6 --- /dev/null +++ b/src/webapp/pages/AddDataSetSettingsPage.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { DataSetConfiguration } from "../../domain/entities/DataSetConfiguration"; +import { Alert } from "../components/alert/Alert"; +import { DataSetConfigForm } from "../components/dataset-config/DataSetConfigForm"; +import { useAppContext } from "../contexts/app-context"; + +export const AddDataSetSettingsPage = () => { + const { id } = useParams(); + const { compositionRoot } = useAppContext(); + const [configuration, setConfiguration] = React.useState(); + const [isLoading, setIsLoading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(""); + const navigate = useNavigate(); + + React.useEffect(() => { + if (!id) { + setConfiguration(DataSetConfiguration.initial()); + return; + } + + setIsLoading(true); + return compositionRoot.dataSetConfig.getByCode.execute({ id }).run( + configuration => { + setConfiguration(configuration); + setIsLoading(false); + }, + () => { + setIsLoading(false); + } + ); + }, [compositionRoot, id]); + + const goToList = React.useCallback(() => { + navigate("/datasets-settings/list"); + }, [navigate]); + + const updateConfiguration = React.useCallback((newData: DataSetConfiguration) => { + setConfiguration(newData); + }, []); + + const saveConfiguration = React.useCallback( + (config: DataSetConfiguration) => { + setIsLoading(true); + compositionRoot.dataSetConfig.save.execute(config).run( + () => { + goToList(); + setErrorMessage(""); + setIsLoading(false); + }, + error => { + setIsLoading(false); + setErrorMessage(error.message); + } + ); + }, + [compositionRoot, goToList] + ); + + if (!configuration) return null; + + return ( +
+ {errorMessage && } + + +
+ ); +}; diff --git a/src/webapp/pages/DataSetSettingsPage.tsx b/src/webapp/pages/DataSetSettingsPage.tsx new file mode 100644 index 0000000..116cc77 --- /dev/null +++ b/src/webapp/pages/DataSetSettingsPage.tsx @@ -0,0 +1,87 @@ +import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { DataSetConfiguration } from "../../domain/entities/DataSetConfiguration"; +import i18n from "../../locales"; +import { DataSetConfigTable } from "../components/dataset-config/DataSetConfigTable"; +import { useAppContext } from "../contexts/app-context"; + +export const DataSetSettingsPage = () => { + const { compositionRoot, config } = useAppContext(); + const navigate = useNavigate(); + const [dataSetConfigs, setDataSetConfigs] = React.useState([]); + const [selectedConfigId, setSelectedConfigId] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + + React.useEffect(() => { + setIsLoading(true); + return compositionRoot.dataSetConfig.getAll.execute().run( + data => { + setDataSetConfigs(data); + setIsLoading(false); + }, + error => { + console.error(error); + setIsLoading(false); + } + ); + }, [compositionRoot.dataSetConfig]); + + const goToAddConfig = () => { + navigate("/datasets-settings/add"); + }; + + const onAction = (params: { action: string; item: DataSetConfiguration }) => { + switch (params.action) { + case "edit": { + navigate(`/datasets-settings/${params.item.id}/edit`); + break; + } + case "delete": { + setSelectedConfigId(params.item.id); + break; + } + default: + throw new Error(`Unknown action ${params.action}`); + } + }; + + const deleteConfiguration = () => { + if (!selectedConfigId) return; + + setIsLoading(true); + compositionRoot.dataSetConfig.remove.execute(selectedConfigId).run( + () => { + setDataSetConfigs(prevConfigs => prevConfigs.filter(config => config.id !== selectedConfigId)); + setSelectedConfigId(""); + setIsLoading(false); + }, + () => { + setIsLoading(false); + } + ); + }; + + return ( +
+ setSelectedConfigId("")} + open={selectedConfigId.length > 0} + disableSave={isLoading} + /> + + +
+ ); +}; diff --git a/src/webapp/pages/DataSetSettingsRootPage.tsx b/src/webapp/pages/DataSetSettingsRootPage.tsx new file mode 100644 index 0000000..f8e709c --- /dev/null +++ b/src/webapp/pages/DataSetSettingsRootPage.tsx @@ -0,0 +1,33 @@ +import { Outlet, useMatch, useNavigate, useParams } from "react-router-dom"; +import styled from "styled-components"; +import PageHeader from "../components/page-header/PageHeader"; +import i18n from "../../locales"; + +const settingsListPath = "/datasets-settings/list"; + +export const DataSetSettingsRootPage = () => { + const navigate = useNavigate(); + const params = useParams(); + const match = useMatch(settingsListPath); + + const goToList = () => { + navigate(match ? "/" : settingsListPath); + }; + + const isEdit = Boolean(params.id); + const formTitle = isEdit ? i18n.t("Edit Configuration") : i18n.t("Add Configuration"); + + const title = match ? i18n.t("DataSet Configurations") : formTitle; + + return ( + + + + + + ); +}; + +const Container = styled.section` + padding: 1em; +`; diff --git a/src/webapp/reports/Reports.tsx b/src/webapp/reports/Reports.tsx index 0700ed9..ada4bf8 100644 --- a/src/webapp/reports/Reports.tsx +++ b/src/webapp/reports/Reports.tsx @@ -1,12 +1,71 @@ -import React from "react"; +import { LinearProgress } from "@material-ui/core"; +import _ from "lodash"; +import { Navigate, RouterProvider } from "react-router"; +import { createHashRouter, RouteObject } from "react-router-dom"; +import { CompositionRoot } from "../../compositionRoot"; +import { Config } from "../../domain/common/entities/Config"; +import { DataSetWithConfigPermissions } from "../../domain/usecases/GetApprovalConfigurationsUseCase"; +import { AddDataSetSettingsPage } from "../pages/AddDataSetSettingsPage"; +import { DataSetSettingsPage } from "../pages/DataSetSettingsPage"; +import { DataSetSettingsRootPage } from "../pages/DataSetSettingsRootPage"; import MalDataApprovalStatusReport from "./mal-data-approval/MalDataApprovalReport"; -const Component: React.FC = () => { - return ; -}; +function generateRoutes(options: { config: Config; compositionRoot: CompositionRoot }) { + const { config, compositionRoot } = options; + const dataSetSettingsRoutes: RouteObject = { + path: "/datasets-settings", + element: , + children: [ + { + index: true, + element: , + }, + { + element: , + path: "list", + }, + { + path: "add", + element: , + }, + { + path: ":id/edit", + element: , + }, + ], + }; -function Reports() { - return ; + const routes: RouteObject[] = _([ + { + path: "/", + element: , + loader: () => { + return getDataSetConfigurations(compositionRoot); + }, + hydrateFallbackElement: , + }, + config.currentUser.isAdmin ? dataSetSettingsRoutes : undefined, + ]) + .compact() + .value(); + + return createHashRouter(routes, { future: { v7_partialHydration: true } }); +} + +function Reports(props: { config: Config; compositionRoot: CompositionRoot }) { + const routes = generateRoutes(props); + return ; } export default Reports; + +export function getDataSetConfigurations(compositionRoot: CompositionRoot): Promise { + return compositionRoot.dataSetConfig.getDataSets + .execute() + .toPromise() + .then(dataSetsConfig => { + return { dataSetsConfig }; + }); +} + +export type DataSetConfigLoader = { dataSetsConfig: DataSetWithConfigPermissions[] }; diff --git a/src/webapp/reports/admin/AdminReport.tsx b/src/webapp/reports/admin/AdminReport.tsx deleted file mode 100644 index 6b57db5..0000000 --- a/src/webapp/reports/admin/AdminReport.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import { MetadataObjectsWithInvalidSSList } from "./metadata-list/MetadataObjectsWithInvalidSSList"; -import { MetadataPublicObjectsList } from "./metadata-list/MetadataPublicObjectsList"; - -export const AdminReport: React.FC = () => { - const classes = useStyles(); - - return ( -
- - {i18n.t("Metadata Admin Report")} - - - - {i18n.t("Objects with invalid sharing settings")} - - - - - {i18n.t("Public Objects")} - - -
- ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 10 }, -}); diff --git a/src/webapp/reports/admin/AdminReportViewModel.ts b/src/webapp/reports/admin/AdminReportViewModel.ts deleted file mode 100644 index 48f9265..0000000 --- a/src/webapp/reports/admin/AdminReportViewModel.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Id } from "../../../domain/common/entities/Base"; -import { MetadataObject } from "../../../domain/common/entities/MetadataObject"; - -export interface AdminReportViewModel { - id: Id; - name: string; - metadataType: string; - publicAccess: string; - createdBy: string; - lastUpdatedBy: string; - userGroupAccess: string; - userAccess: string; - created: string; - lastUpdated: string; -} - -export function getAdminReportViews(metadataObjects: MetadataObject[]): AdminReportViewModel[] { - return metadataObjects.map(object => { - return { - id: object.Id, - name: object.name, - metadataType: object.metadataType, - publicAccess: object.publicAccess, - createdBy: object.createdBy ?? "-", - lastUpdatedBy: object.lastUpdatedBy ?? "-", - userGroupAccess: object.userGroupAccess ?? "-", - userAccess: object.userAccess ?? "-", - created: object.created ?? "-", - lastUpdated: object.lastUpdated ?? "-", - }; - }); -} diff --git a/src/webapp/reports/admin/metadata-list/MetadataObjectsWithInvalidSSList.tsx b/src/webapp/reports/admin/metadata-list/MetadataObjectsWithInvalidSSList.tsx deleted file mode 100644 index bf12d87..0000000 --- a/src/webapp/reports/admin/metadata-list/MetadataObjectsWithInvalidSSList.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { - PaginationOptions, - TableColumn, - TableGlobalAction, - TablePagination, - TableSorting, -} from "@eyeseetea/d2-ui-components"; -import StorageIcon from "@material-ui/icons/Storage"; -import React from "react"; -import { MetadataObject } from "../../../../domain/common/entities/MetadataObject"; -import { Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import i18n from "../../../../locales"; -import { useAppContext } from "../../../contexts/app-context"; -import { useSnackbarOnError } from "../../../utils/snackbar"; -import { getAdminReportViews, AdminReportViewModel } from "../AdminReportViewModel"; -import { TableConfig, useObjectsTable } from "../../../components/objects-list/objects-list-hooks"; -import { ObjectsList } from "../../../components/objects-list/ObjectsList"; - -export const MetadataObjectsWithInvalidSSList: React.FC = React.memo(() => { - const { compositionRoot } = useAppContext(); - const baseConfig = React.useMemo(getBaseListConfig, []); - const [sorting, setSorting] = React.useState>(); - - const getRows = React.useMemo( - () => async (paging: TablePagination, sorting: TableSorting) => { - setSorting(sorting); - const objects = getAdminReportViews( - await compositionRoot.admin.get({ - sorting: getSortingFromTableSorting(sorting), - publicObjects: false, - removeTypes: [ - "programs", - "dataSets", - "categoryOptions", - "trackedEntityTypes", - "relationshipTypes", - "programStages", - ], - }) - ); - paging.total = objects.length; - paging.page = 1; - paging.pageSize = 20; - return { - objects: objects, - pager: paging, - }; - }, - [compositionRoot] - ); - - const getRowsWithSnackbarOrError = useSnackbarOnError(getRows); - const tableProps = useObjectsTable(baseConfig, getRowsWithSnackbarOrError); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - - compositionRoot.admin.save( - "metadata-objects.csv", - await compositionRoot.admin.get({ - sorting: getSortingFromTableSorting(sorting), - publicObjects: false, - removeTypes: [ - "programs", - "dataSets", - "categoryOptions", - "trackedEntityTypes", - "relationshipTypes", - "programStages", - ], - }) - ); - }, - }; - - return {...tableProps} globalActions={[downloadCsv]}>; -}); - -function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "metadataType" : sorting.field, - direction: sorting.order, - }; -} - -function getBaseListConfig(): TableConfig { - const paginationOptions: PaginationOptions = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 20, - }; - - const initialSorting: TableSorting = { - field: "metadataType" as const, - order: "asc" as const, - }; - - const columns: TableColumn[] = [ - { name: "id", text: i18n.t("Id"), sortable: true }, - { name: "metadataType", text: i18n.t("Metadata Type"), sortable: true }, - { name: "publicAccess", text: i18n.t("Public Access"), sortable: true }, - { name: "createdBy", text: i18n.t("Created By"), sortable: true }, - { name: "lastUpdatedBy", text: i18n.t("Last Updated By"), sortable: true }, - { name: "userGroupAccess", text: i18n.t("User Group Accesses"), sortable: true }, - { name: "userAccess", text: i18n.t("User Accesses"), sortable: true }, - { name: "name", text: i18n.t("name"), sortable: true }, - { name: "lastUpdated", text: i18n.t("lastUpdated"), sortable: true }, - { name: "created", text: i18n.t("created"), sortable: true }, - ]; - - return { columns, initialSorting, paginationOptions }; -} diff --git a/src/webapp/reports/admin/metadata-list/MetadataPublicObjectsList.tsx b/src/webapp/reports/admin/metadata-list/MetadataPublicObjectsList.tsx deleted file mode 100644 index a5f7d4f..0000000 --- a/src/webapp/reports/admin/metadata-list/MetadataPublicObjectsList.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { - PaginationOptions, - TableColumn, - TableGlobalAction, - TablePagination, - TableSorting, -} from "@eyeseetea/d2-ui-components"; -import StorageIcon from "@material-ui/icons/Storage"; -import React from "react"; -import { MetadataObject } from "../../../../domain/common/entities/MetadataObject"; -import { Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import i18n from "../../../../locales"; -import { useAppContext } from "../../../contexts/app-context"; -import { useSnackbarOnError } from "../../../utils/snackbar"; -import { getAdminReportViews, AdminReportViewModel } from "../AdminReportViewModel"; -import { TableConfig, useObjectsTable } from "../../../components/objects-list/objects-list-hooks"; -import { ObjectsList } from "../../../components/objects-list/ObjectsList"; - -export const MetadataPublicObjectsList: React.FC = React.memo(() => { - const { compositionRoot } = useAppContext(); - const baseConfig = React.useMemo(getBaseListConfig, []); - const [sorting, setSorting] = React.useState>(); - - const getRows = React.useMemo( - () => async (paging: TablePagination, sorting: TableSorting) => { - setSorting(sorting); - const objects = getAdminReportViews( - await compositionRoot.admin.get({ - sorting: getSortingFromTableSorting(sorting), - publicObjects: true, - removeTypes: [], - }) - ); - paging.total = objects.length; - paging.page = 1; - paging.pageSize = 20; - return { - objects: objects, - pager: paging, - }; - }, - [compositionRoot] - ); - - const getRowsWithSnackbarOrError = useSnackbarOnError(getRows); - const tableProps = useObjectsTable(baseConfig, getRowsWithSnackbarOrError); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - - compositionRoot.admin.save( - "metadata-objects.csv", - await compositionRoot.admin.get({ - sorting: getSortingFromTableSorting(sorting), - publicObjects: true, - removeTypes: [], - }) - ); - }, - }; - - return {...tableProps} globalActions={[downloadCsv]}>; -}); - -function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "metadataType" : sorting.field, - direction: sorting.order, - }; -} - -function getBaseListConfig(): TableConfig { - const paginationOptions: PaginationOptions = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 20, - }; - - const initialSorting: TableSorting = { - field: "metadataType" as const, - order: "asc" as const, - }; - - const columns: TableColumn[] = [ - { name: "id", text: i18n.t("Id"), sortable: true }, - { name: "metadataType", text: i18n.t("Metadata Type"), sortable: true }, - { name: "publicAccess", text: i18n.t("Public Access"), sortable: true }, - { name: "createdBy", text: i18n.t("Created By"), sortable: true }, - { name: "lastUpdatedBy", text: i18n.t("Last Updated By"), sortable: true }, - { name: "userGroupAccess", text: i18n.t("User Group Accesses"), sortable: true }, - { name: "userAccess", text: i18n.t("User Accesses"), sortable: true }, - { name: "name", text: i18n.t("name"), sortable: true }, - { name: "lastUpdated", text: i18n.t("lastUpdated"), sortable: true }, - { name: "created", text: i18n.t("created"), sortable: true }, - ]; - - return { columns, initialSorting, paginationOptions }; -} diff --git a/src/webapp/reports/authorities-monitoring/AuthoritiesMonitoringReport.tsx b/src/webapp/reports/authorities-monitoring/AuthoritiesMonitoringReport.tsx deleted file mode 100644 index a967a7e..0000000 --- a/src/webapp/reports/authorities-monitoring/AuthoritiesMonitoringReport.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import React from "react"; -import { AuthoritiesMonitoringList } from "./authorities-monitoring-list/AuthoritiesMonitoringList"; - -const AuthoritiesMonitoringReport: React.FC = () => { - const classes = useStyles(); - - return ( -
- - {i18n.t("WIDP Admin Templates Report")} - -

{i18n.t("List of users with not allowed roles")}

- - -
- ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default AuthoritiesMonitoringReport; diff --git a/src/webapp/reports/authorities-monitoring/DataMonitoringViewModel.ts b/src/webapp/reports/authorities-monitoring/DataMonitoringViewModel.ts deleted file mode 100644 index ca00e3f..0000000 --- a/src/webapp/reports/authorities-monitoring/DataMonitoringViewModel.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { - AuthoritiesMonitoringItem, - getDataMonitoringItemId, -} from "../../../domain/reports/authorities-monitoring/entities/AuthoritiesMonitoringItem"; - -export interface DataMonitoringViewModel { - id: string; - uid: string; - name: string; - lastLogin: string; - username: string; - templateGroups: string; - roles: string; - authorities: string; -} - -export function getDataMonitoringViews(items: AuthoritiesMonitoringItem[]): DataMonitoringViewModel[] { - return items.map(item => { - return { - id: getDataMonitoringItemId(item), - uid: item.id, - name: item.name, - lastLogin: item.lastLogin, - username: item.username, - templateGroups: item.templateGroups.join(", "), - roles: item.roles.map(role => role.name).join(", "), - authorities: item.authorities.join(", "), - }; - }); -} diff --git a/src/webapp/reports/authorities-monitoring/authorities-monitoring-list/AuthoritiesMonitoringList.tsx b/src/webapp/reports/authorities-monitoring/authorities-monitoring-list/AuthoritiesMonitoringList.tsx deleted file mode 100644 index 8ddc5f9..0000000 --- a/src/webapp/reports/authorities-monitoring/authorities-monitoring-list/AuthoritiesMonitoringList.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { - ObjectsList, - TableColumn, - TableConfig, - TableGlobalAction, - TablePagination, - TableSorting, - useObjectsTable, -} from "@eyeseetea/d2-ui-components"; -import StorageIcon from "@material-ui/icons/Storage"; -import { useAppContext } from "../../../contexts/app-context"; -import { useReload } from "../../../utils/use-reload"; -import { Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import i18n from "../../../../locales"; -import { DataMonitoringViewModel, getDataMonitoringViews } from "../DataMonitoringViewModel"; -import { Filter, Filters } from "./Filters"; -import _ from "lodash"; -import { Namespaces } from "../../../../data/common/clients/storage/Namespaces"; -import { AuthoritiesMonitoringItem } from "../../../../domain/reports/authorities-monitoring/entities/AuthoritiesMonitoringItem"; -import { UserRole } from "../../../../domain/reports/authorities-monitoring/entities/UserPermissions"; - -export const AuthoritiesMonitoringList: React.FC = React.memo(() => { - const { compositionRoot } = useAppContext(); - - const [filters, setFilters] = useState(() => getEmptyDataValuesFilter()); - const [sorting, setSorting] = useState>(); - const [templateGroups, setTemplateGroups] = useState([]); - const [usernameQuery, setUsernameQuery] = useState(""); - const [userRoles, setUserRoles] = useState([]); - const [visibleColumns, setVisibleColumns] = useState(); - const [reloadKey, _reload] = useReload(); - - useEffect(() => { - compositionRoot.authMonitoring.getColumns(Namespaces.AUTH_MONITORING_USER_COLUMNS).then(columns => { - setVisibleColumns(columns); - }); - }, [compositionRoot.authMonitoring]); - - const baseConfig: TableConfig = useMemo( - () => ({ - columns: [ - { name: "uid", text: i18n.t("ID"), sortable: true }, - { name: "name", text: i18n.t("Name"), sortable: true }, - { name: "username", text: i18n.t("Username"), sortable: false }, - { name: "templateGroups", text: i18n.t("Template Groups"), sortable: false }, - { name: "lastLogin", text: i18n.t("Last login"), sortable: false }, - { name: "roles", text: i18n.t("Role"), sortable: false }, - { name: "authorities", text: i18n.t("Unauthorized privileges"), sortable: false }, - ], - actions: [], - initialSorting: { - field: "name" as const, - order: "asc" as const, - }, - paginationOptions: { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, - }, - searchBoxLabel: i18n.t("Search by username..."), - }), - [] - ); - - const getRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects, templateGroups, userRoles } = await compositionRoot.authMonitoring.get( - Namespaces.AUTH_MONITORING, - { - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - } - ); - - setSorting(sorting); - setUserRoles(userRoles); - setTemplateGroups(templateGroups); - - console.debug("Reloading", reloadKey); - - return { pager, objects: getDataMonitoringViews(objects) }; - }, - [compositionRoot.authMonitoring, filters, reloadKey] - ); - - const saveReorderedColumns = useCallback( - async (columnKeys: Array) => { - if (!visibleColumns) return; - - await compositionRoot.authMonitoring.saveColumns(Namespaces.AUTH_MONITORING_USER_COLUMNS, columnKeys); - }, - [compositionRoot.authMonitoring, visibleColumns] - ); - - const tableProps = useObjectsTable(baseConfig, getRows); - - const filterOptions = useMemo(() => { - return { - usernameQuery: usernameQuery, - templateGroups: templateGroups, - userRoles: userRoles, - }; - }, [templateGroups, userRoles, usernameQuery]); - - const columnsToShow = useMemo[]>(() => { - if (!visibleColumns || _.isEmpty(visibleColumns)) return tableProps.columns; - - const indexes = _(visibleColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(tableProps.columns) - .map(column => ({ ...column, hidden: !visibleColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [tableProps.columns, visibleColumns]); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - const { objects: authMonitoringItems } = await compositionRoot.authMonitoring.get( - Namespaces.AUTH_MONITORING, - { - paging: { page: 1, pageSize: 100000 }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - } - ); - - compositionRoot.authMonitoring.save("authorities-monitoring-report.csv", authMonitoringItems); - }, - }; - - return ( - - {...tableProps} - columns={columnsToShow} - onReorderColumns={saveReorderedColumns} - onChangeSearch={value => { - setUsernameQuery(value); - setFilters({ ...filters, usernameQuery: value }); - }} - globalActions={[downloadCsv]} - > - - - ); -}); - -export function getSortingFromTableSorting( - sorting: TableSorting -): Sorting { - return { - field: sorting.field === "uid" ? "name" : sorting.field, - direction: sorting.order, - }; -} - -function getEmptyDataValuesFilter(): Filter { - return { - templateGroups: [], - userRoles: [], - usernameQuery: "", - }; -} diff --git a/src/webapp/reports/authorities-monitoring/authorities-monitoring-list/Filters.tsx b/src/webapp/reports/authorities-monitoring/authorities-monitoring-list/Filters.tsx deleted file mode 100644 index f339b23..0000000 --- a/src/webapp/reports/authorities-monitoring/authorities-monitoring-list/Filters.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useMemo } from "react"; -import styled from "styled-components"; -import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import _ from "lodash"; -import { IconButton } from "material-ui"; -import { FilterList } from "@material-ui/icons"; -import { useBooleanState } from "../../../utils/use-boolean"; -import { MultiSelectorFilterButton } from "../../../components/multi-selector/MultiSelectorFilterButton"; -import { NamedRef } from "../../../../domain/common/entities/Ref"; -import { UserRole } from "../../../../domain/reports/authorities-monitoring/entities/UserPermissions"; - -export interface DataSetsFiltersProps { - values: Filter; - options: FilterOptions; - onChange: React.Dispatch>; -} - -export interface Filter { - usernameQuery: string; - templateGroups: string[]; - userRoles: string[]; -} - -interface FilterOptions { - usernameQuery: string; - templateGroups: string[]; - userRoles: UserRole[]; -} - -export const Filters: React.FC = React.memo(props => { - const { values: filter, options: filterOptions, onChange } = props; - const [isDialogOpen, { enable: openDialog, disable: closeDialog }] = useBooleanState(false); - - const templateGroupItems = useMemoOptionsFromStrings(filterOptions.templateGroups); - const userRoleItems = useMemoOptionsFromNamedRef(filterOptions.userRoles); - - const setTemplateGroups = React.useCallback( - templateGroups => onChange(prev => ({ ...prev, templateGroups })), - [onChange] - ); - const setUserRoles = React.useCallback(userRoles => onChange(prev => ({ ...prev, userRoles })), [onChange]); - - const selectedUserRoles = filterOptions.userRoles - .filter(role => filter.userRoles.includes(role.id)) - .map(role => role.name) - .join(", "); - - return ( - - - - - - - - - - - - ); -}); - -function useMemoOptionsFromStrings(options: string[]) { - return useMemo(() => { - return _(options) - .map(option => ({ value: option, text: option })) - .value(); - }, [options]); -} - -function useMemoOptionsFromNamedRef(options: NamedRef[]) { - return useMemo(() => { - return options.map(option => ({ value: option.id, text: option.name })); - }, [options]); -} - -const Container = styled.div` - display: flex; - gap: 1rem; - flex-wrap: wrap; -`; diff --git a/src/webapp/reports/csy-audit-emergency/AuditViewModel.ts b/src/webapp/reports/csy-audit-emergency/AuditViewModel.ts deleted file mode 100644 index b337477..0000000 --- a/src/webapp/reports/csy-audit-emergency/AuditViewModel.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AuditItem } from "../../../domain/reports/csy-audit-trauma/entities/AuditItem"; - -export interface AuditViewModel { - id: string; - registerId: string; -} - -export function getAuditViews(items: AuditItem[]): AuditViewModel[] { - return items.map((item, i) => { - return { - id: i.toString(), - registerId: item.registerId, - }; - }); -} diff --git a/src/webapp/reports/csy-audit-emergency/CSYAuditEmergencyReport.tsx b/src/webapp/reports/csy-audit-emergency/CSYAuditEmergencyReport.tsx deleted file mode 100644 index c48b20b..0000000 --- a/src/webapp/reports/csy-audit-emergency/CSYAuditEmergencyReport.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import { CSYAuditEmergencyList } from "./csy-audit-emergency-list/CSYAuditEmergencyList"; - -const CSYAuditEmergencyReport: React.FC = () => { - const classes = useStyles(); - - return ( -
- - {i18n.t("CSY Audit Filters - Emergency Care")} - - - -
- ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default CSYAuditEmergencyReport; diff --git a/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/CSYAuditEmergencyList.tsx b/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/CSYAuditEmergencyList.tsx deleted file mode 100644 index 1b57970..0000000 --- a/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/CSYAuditEmergencyList.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { useMemo, useState } from "react"; -import { Filter, Filters } from "./Filters"; -import { AuditViewModel } from "../AuditViewModel"; -import { ObjectsList, TableConfig, useObjectsTable } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import { useAuditReport } from "./useAuditReport"; - -export const CSYAuditEmergencyList: React.FC = React.memo(() => { - const [filters, setFilters] = useState(() => getEmptyDataValuesFilter()); - const { auditDefinition, downloadCsv, filterOptions, initialSorting, paginationOptions, getRows } = - useAuditReport(filters); - - const baseConfig: TableConfig = useMemo( - () => ({ - columns: [{ name: "registerId", text: i18n.t("Register ID"), sortable: true }], - actions: [], - initialSorting: initialSorting, - paginationOptions: paginationOptions, - }), - [initialSorting, paginationOptions] - ); - - const tableProps = useObjectsTable(baseConfig, getRows); - - return ( - - {...tableProps} onChangeSearch={undefined} globalActions={[downloadCsv]}> -
- -

- Audit Definition: {auditDefinition} -

-
- -
- ); -}); - -function getEmptyDataValuesFilter(): Filter { - return { - auditType: "overallMortality", - orgUnitPaths: [], - year: (new Date().getFullYear() - 1).toString(), - periodType: "yearly", - quarter: undefined, - }; -} diff --git a/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/Filters.tsx b/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/Filters.tsx deleted file mode 100644 index 1c5b05d..0000000 --- a/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/Filters.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import React, { useMemo, useState } from "react"; -import { OrgUnitsFilterButton } from "../../../components/org-units-filter/OrgUnitsFilterButton"; -import { useAppContext } from "../../../contexts/app-context"; -import { Id } from "../../../../domain/common/entities/Base"; -import styled from "styled-components"; -import { Dropdown, DropdownProps } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import _ from "lodash"; -import { AuditType } from "../../../../domain/reports/csy-audit-emergency/entities/AuditItem"; - -export interface FiltersProps { - values: Filter; - options: FilterOptions; - onChange: React.Dispatch>; -} - -export interface Filter { - auditType: AuditType; - orgUnitPaths: Id[]; - periodType: string; - year: string; - quarter?: string; -} - -export type FilterOptions = { - periods: string[]; -}; - -export const Filters: React.FC = React.memo(props => { - const { config, api } = useAppContext(); - const { values: filter, options: filterOptions, onChange } = props; - - const [periodType, setPerType] = useState("yearly"); - const rootIds = React.useMemo( - () => - _(config.currentUser.orgUnits) - .map(ou => ou.id) - .value(), - [config] - ); - - const periodTypeItems = React.useMemo(() => { - return [ - { value: "yearly", text: i18n.t("Yearly") }, - { value: "quarterly", text: i18n.t("Quarterly") }, - ]; - }, []); - - const yearItems = useMemoOptionsFromStrings(filterOptions.periods); - - const quarterPeriodItems = React.useMemo(() => { - return [ - { value: "Q1", text: i18n.t("Jan - March") }, - { value: "Q2", text: i18n.t("April - June") }, - { value: "Q3", text: i18n.t("July - September") }, - { value: "Q4", text: i18n.t("October - December") }, - ]; - }, []); - - const setAuditType = React.useCallback( - auditType => { - onChange(filter => ({ ...filter, auditType: auditType as AuditType })); - }, - [onChange] - ); - - const setQuarterPeriod = React.useCallback( - quarterPeriod => { - onChange(filter => ({ ...filter, quarter: quarterPeriod ?? "" })); - }, - [onChange] - ); - - const setPeriodType = React.useCallback( - periodType => { - setPerType(periodType ?? "yearly"); - setQuarterPeriod(periodType !== "yearly" ? "Q1" : undefined); - - onChange(filter => ({ ...filter, periodType: periodType ?? "yearly" })); - }, - [onChange, setQuarterPeriod] - ); - - const setYear = React.useCallback( - year => { - onChange(filter => ({ ...filter, year: year ?? "" })); - }, - [onChange] - ); - - return ( - - - - onChange({ ...filter, orgUnitPaths: paths })} - selectableLevels={[1, 2, 3, 4, 5, 6, 7]} - /> - - - - - - {periodType === "quarterly" && ( - <> - - - )} - - ); -}); - -export const auditTypeItems = [ - { - value: "overallMortality", - text: i18n.t("Overall Mortality in EU"), - auditDefinition: i18n.t("ETA_EU Dispo = Morgue or Died or ETA_Facility Dispo = Morgue or Died"), - }, - { - value: "lowAcuity", - text: i18n.t("Low acuity triage with EU disposition ICU"), - auditDefinition: i18n.t("EU dispo = ICU AND Triage category = lowest acuity triage category"), - }, - { - value: "highestTriage", - text: i18n.t("Highest triage category and time to first provider >30min​"), - auditDefinition: i18n.t( - "Triage category = highest category AND time between EU arrival date and time to Date and time seen by a first treating provider > 30 min" - ), - }, - { - value: "initialRbg", - text: i18n.t("Initial RBG low and Glucose not given"), - auditDefinition: i18n.t("Initial RBG = Low AND Glucose not given at EU"), - }, - { - value: "shockIvf", - text: i18n.t("Shock and IVF including Blood"), - auditDefinition: i18n.t( - "(Age>=16 OR Age category = adult - age unknown) AND Initial SBP<90mmHg AND (Section: Emergency Unit Interventions > Medications and Fluids) IV Fluids = not done", - { nsSeparator: false } - ), - }, -]; - -function useMemoOptionsFromStrings(options: string[]) { - return useMemo(() => { - return options.map(option => ({ value: option, text: option })); - }, [options]); -} - -const Container = styled.div` - display: flex; - gap: 1rem; - flex-wrap: wrap; -`; - -const AuditTypeDropdown = styled(Dropdown)` - margin-left: -10px; - width: 420px; -`; - -const SingleDropdownStyled = styled(Dropdown)` - margin-left: -10px; - width: 180px; -`; - -type SingleDropdownHandler = DropdownProps["onChange"]; diff --git a/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/useAuditReport.tsx b/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/useAuditReport.tsx deleted file mode 100644 index c8f19d1..0000000 --- a/src/webapp/reports/csy-audit-emergency/csy-audit-emergency-list/useAuditReport.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useSnackbar, TableSorting, TablePagination, TableGlobalAction } from "@eyeseetea/d2-ui-components"; -import _ from "lodash"; -import { useState, useMemo } from "react"; -import { useAppContext } from "../../../contexts/app-context"; -import { useReload } from "../../../utils/use-reload"; -import { AuditViewModel, getAuditViews } from "../AuditViewModel"; -import { auditTypeItems, Filter, FilterOptions } from "./Filters"; -import { emptyPage, PaginatedObjects, Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import StorageIcon from "@material-ui/icons/Storage"; -import { AuditItem } from "../../../../domain/reports/csy-audit-emergency/entities/AuditItem"; - -interface AuditReportState { - auditDefinition: string; - downloadCsv: TableGlobalAction; - filterOptions: FilterOptions; - initialSorting: TableSorting; - paginationOptions: { - pageSizeOptions: number[]; - pageSizeInitialValue: number; - }; - getRows: ( - search: string, - paging: TablePagination, - sorting: TableSorting - ) => Promise>; -} - -const initialSorting = { - field: "registerId" as const, - order: "asc" as const, -}; - -const paginationOptions = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, -}; - -export function useAuditReport(filters: Filter): AuditReportState { - const { compositionRoot } = useAppContext(); - - const [reloadKey, _reload] = useReload(); - const snackbar = useSnackbar(); - const [sorting, setSorting] = useState>(); - - const selectablePeriods = useMemo(() => { - const currentYear = new Date().getFullYear(); - return _.range(currentYear - 10, currentYear + 1).map(year => year.toString()); - }, []); - const filterOptions = useMemo(() => getFilterOptions(selectablePeriods), [selectablePeriods]); - - const auditDefinition = - auditTypeItems.find(auditTypeItem => auditTypeItem.value === filters.auditType)?.auditDefinition ?? ""; - - const getRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects } = await compositionRoot.auditEmergency - .get({ - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }) - .catch(error => { - snackbar.error(error.message); - return emptyPage; - }); - - setSorting(sorting); - console.debug("Reloading", reloadKey); - return { pager, objects: getAuditViews(objects) }; - }, - [compositionRoot.auditEmergency, filters, reloadKey, snackbar] - ); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - const { objects: auditItems } = await compositionRoot.auditEmergency.get({ - paging: { page: 1, pageSize: 100000 }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }); - - compositionRoot.auditEmergency.save("audit-report.csv", auditItems); - }, - }; - - return { - auditDefinition, - filterOptions, - initialSorting, - paginationOptions, - getRows, - downloadCsv, - }; -} - -export function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "registerId" : sorting.field, - direction: sorting.order, - }; -} - -function getFilterOptions(selectablePeriods: string[]): FilterOptions { - return { - periods: selectablePeriods, - }; -} diff --git a/src/webapp/reports/csy-audit-trauma/AuditViewModel.ts b/src/webapp/reports/csy-audit-trauma/AuditViewModel.ts deleted file mode 100644 index faf6e16..0000000 --- a/src/webapp/reports/csy-audit-trauma/AuditViewModel.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { AuditItem } from "../../../domain/reports/csy-audit-emergency/entities/AuditItem"; - -export interface AuditViewModel { - id: string; - registerId: string; -} - -export function getAuditViews(items: AuditItem[]): AuditViewModel[] { - return items.map((item, i) => { - return { - id: i.toString(), - registerId: item.registerId, - }; - }); -} diff --git a/src/webapp/reports/csy-audit-trauma/CSYAuditTraumaReport.tsx b/src/webapp/reports/csy-audit-trauma/CSYAuditTraumaReport.tsx deleted file mode 100644 index 676b1a3..0000000 --- a/src/webapp/reports/csy-audit-trauma/CSYAuditTraumaReport.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import { CSYAuditTraumaList } from "./csy-audit-trauma-list/CSYAuditTraumaList"; - -const CSYAuditTraumaReport: React.FC = () => { - const classes = useStyles(); - - return ( -
- - {i18n.t("CSY Audit Filters - Trauma Care")} - - - -
- ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default CSYAuditTraumaReport; diff --git a/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/CSYAuditTraumaList.tsx b/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/CSYAuditTraumaList.tsx deleted file mode 100644 index 6343b22..0000000 --- a/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/CSYAuditTraumaList.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React, { useMemo, useState } from "react"; -import { Filter, Filters } from "./Filters"; -import { AuditViewModel } from "../AuditViewModel"; -import { ObjectsList, TableConfig, useObjectsTable } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import { useAuditReport } from "./useAuditReport"; - -export const CSYAuditTraumaList: React.FC = React.memo(() => { - const [filters, setFilters] = useState(() => getEmptyDataValuesFilter()); - const { auditDefinition, downloadCsv, filterOptions, initialSorting, paginationOptions, getRows } = - useAuditReport(filters); - - const baseConfig: TableConfig = useMemo( - () => ({ - columns: [{ name: "registerId", text: i18n.t("Register ID"), sortable: true }], - actions: [], - initialSorting: initialSorting, - paginationOptions: paginationOptions, - }), - [initialSorting, paginationOptions] - ); - const tableProps = useObjectsTable(baseConfig, getRows); - - return ( - - {...tableProps} onChangeSearch={undefined} globalActions={[downloadCsv]}> -
- -

- Audit Definition: {auditDefinition} -

-
- -
- ); -}); - -function getEmptyDataValuesFilter(): Filter { - return { - auditType: "mortality", - orgUnitPaths: [], - year: (new Date().getFullYear() - 1).toString(), - periodType: "yearly", - quarter: undefined, - }; -} diff --git a/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/Filters.tsx b/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/Filters.tsx deleted file mode 100644 index 7f0384e..0000000 --- a/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/Filters.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { useMemo, useState } from "react"; -import { OrgUnitsFilterButton } from "../../../components/org-units-filter/OrgUnitsFilterButton"; -import { useAppContext } from "../../../contexts/app-context"; -import { Id } from "../../../../domain/common/entities/Base"; -import styled from "styled-components"; -import { Dropdown, DropdownProps } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import _ from "lodash"; -import { AuditType } from "../../../../domain/reports/csy-audit-trauma/entities/AuditItem"; - -export interface FiltersProps { - values: Filter; - options: FilterOptions; - onChange: React.Dispatch>; -} - -export interface Filter { - auditType: AuditType; - orgUnitPaths: Id[]; - periodType: string; - year: string; - quarter?: string; -} - -export type FilterOptions = { - periods: string[]; -}; - -export const Filters: React.FC = React.memo(props => { - const { config, api } = useAppContext(); - const { values: filter, options: filterOptions, onChange } = props; - - const [periodType, setPerType] = useState("yearly"); - - const rootIds = React.useMemo( - () => - _(config.currentUser.orgUnits) - .map(ou => ou.id) - .value(), - [config] - ); - - const periodTypeItems = React.useMemo(() => { - return [ - { value: "yearly", text: i18n.t("Yearly") }, - { value: "quarterly", text: i18n.t("Quarterly") }, - ]; - }, []); - - const yearItems = useMemoOptionsFromStrings(filterOptions.periods); - - const quarterPeriodItems = React.useMemo(() => { - return [ - { value: "Q1", text: i18n.t("Jan - March") }, - { value: "Q2", text: i18n.t("April - June") }, - { value: "Q3", text: i18n.t("July - September") }, - { value: "Q4", text: i18n.t("October - December") }, - ]; - }, []); - - const setAuditType = React.useCallback( - auditType => { - onChange(filter => ({ ...filter, auditType: auditType as AuditType })); - }, - [onChange] - ); - - const setQuarterPeriod = React.useCallback( - quarterPeriod => { - onChange(filter => ({ ...filter, quarter: quarterPeriod ?? "" })); - }, - [onChange] - ); - - const setPeriodType = React.useCallback( - periodType => { - setPerType(periodType ?? "yearly"); - setQuarterPeriod(periodType !== "yearly" ? "Q1" : undefined); - - onChange(filter => ({ ...filter, periodType: periodType ?? "yearly" })); - }, - [onChange, setQuarterPeriod] - ); - - const setYear = React.useCallback( - year => { - onChange(filter => ({ ...filter, year: year ?? "" })); - }, - [onChange] - ); - - return ( - - - - onChange({ ...filter, orgUnitPaths: paths })} - selectableLevels={[1, 2, 3, 4, 5, 6, 7]} - /> - - - - - - {periodType === "quarterly" && ( - <> - - - )} - - ); -}); - -function useMemoOptionsFromStrings(options: string[]) { - return useMemo(() => { - return options.map(option => ({ value: option, text: option })); - }, [options]); -} - -const Container = styled.div` - display: flex; - gap: 1rem; - flex-wrap: wrap; -`; - -const AuditTypeDropdown = styled(Dropdown)` - margin-left: -10px; - width: 420px; -`; - -const SingleDropdownStyled = styled(Dropdown)` - margin-left: -10px; - width: 180px; -`; - -type SingleDropdownHandler = DropdownProps["onChange"]; - -export const auditTypeItems = [ - { - value: "mortality", - text: i18n.t("Mortality with low injury severity score"), - auditDefinition: i18n.t( - "(EU Disposition = Death) OR (Hospital Disposition = Death) AND (KTS=14-16) OR (MGAP=23-29) OR (GAP=19-24) OR (RTS=11-12)" - ), - }, - { - value: "hypoxia", - text: i18n.t("Oxygen not administered for patients with hypoxia"), - auditDefinition: i18n.t("Initial Oxygen Sat < 92 AND EU Procedures != Supplemental Oxygen Administration"), - }, - { - value: "tachypnea", - text: i18n.t("Oxygen not administered for patients with tachypnea"), - auditDefinition: i18n.t( - "Initial Spontaneous RR <12 OR >30 AND EU Procedures != Supplemental Oxygen Administration" - ), - }, - { - value: "mental", - text: i18n.t("Mental status-dependent airway maneuver"), - auditDefinition: i18n.t( - "GCS total < 8 OR AVPU=(P OR U) AND EU Procedures ≠ Endotracheal intubation, Surgical airway, OR Assisted Ventilation" - ), - }, - { - value: "allMortality", - text: i18n.t("All mortality"), - auditDefinition: i18n.t("EU Disposition = Mortuary or Died OR Hospital Disposition = Morgue or Died"), - }, - { - value: "emergencyUnit", - text: i18n.t("Emergency Unit"), - auditDefinition: i18n.t("EU Disposition = Mortuary or Died"), - }, - { - value: "hospitalMortality", - text: i18n.t("Hospital Mortality"), - auditDefinition: i18n.t("Hospital Disposition = Morgue or Died"), - }, - { - value: "severeInjuries", - text: i18n.t("Severe injuries by any scoring system"), - auditDefinition: i18n.t("(KTS<11) OR (MGAP=3-17) OR (GAP=3-10) OR (RTS≤3)"), - }, - { - value: "moderateSevereInjuries", - text: i18n.t("Moderate or severe injuries by any scoring system"), - auditDefinition: i18n.t("(KTS≤13) OR (MGAP≤22) OR (GAP≤18) OR (RTS≤10)"), - }, - { - value: "moderateInjuries", - text: i18n.t("Moderate injuries by any scoring system"), - auditDefinition: i18n.t("(KTS=11-13) OR (MGAP=18-22) OR (GAP=11-18) OR (RTS=4-10)"), - }, -]; diff --git a/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/useAuditReport.tsx b/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/useAuditReport.tsx deleted file mode 100644 index b443ab7..0000000 --- a/src/webapp/reports/csy-audit-trauma/csy-audit-trauma-list/useAuditReport.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useSnackbar, TableSorting, TablePagination, TableGlobalAction } from "@eyeseetea/d2-ui-components"; -import _ from "lodash"; -import { useState, useMemo } from "react"; -import { useAppContext } from "../../../contexts/app-context"; -import { useReload } from "../../../utils/use-reload"; -import { AuditViewModel, getAuditViews } from "../AuditViewModel"; -import { auditTypeItems, Filter, FilterOptions } from "./Filters"; -import { emptyPage, PaginatedObjects, Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import StorageIcon from "@material-ui/icons/Storage"; -import { AuditItem } from "../../../../domain/reports/csy-audit-trauma/entities/AuditItem"; - -interface AuditReportState { - auditDefinition: string; - downloadCsv: TableGlobalAction; - filterOptions: FilterOptions; - initialSorting: TableSorting; - paginationOptions: { - pageSizeOptions: number[]; - pageSizeInitialValue: number; - }; - getRows: ( - search: string, - paging: TablePagination, - sorting: TableSorting - ) => Promise>; -} - -const initialSorting = { - field: "registerId" as const, - order: "asc" as const, -}; - -const paginationOptions = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, -}; - -export function useAuditReport(filters: Filter): AuditReportState { - const { compositionRoot } = useAppContext(); - - const [reloadKey, _reload] = useReload(); - const snackbar = useSnackbar(); - const [sorting, setSorting] = useState>(); - - const selectablePeriods = useMemo(() => { - const currentYear = new Date().getFullYear(); - return _.range(currentYear - 10, currentYear + 1).map(year => year.toString()); - }, []); - const filterOptions = useMemo(() => getFilterOptions(selectablePeriods), [selectablePeriods]); - - const auditDefinition = - auditTypeItems.find(auditTypeItem => auditTypeItem.value === filters.auditType)?.auditDefinition ?? ""; - - const getRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects } = await compositionRoot.auditTrauma - .get({ - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }) - .catch(error => { - snackbar.error(error.message); - return emptyPage; - }); - - setSorting(sorting); - console.debug("Reloading", reloadKey); - return { pager, objects: getAuditViews(objects) }; - }, - [compositionRoot.auditTrauma, filters, reloadKey, snackbar] - ); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - const { objects: auditItems } = await compositionRoot.auditTrauma.get({ - paging: { page: 1, pageSize: 100000 }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }); - - compositionRoot.auditTrauma.save("audit-report.csv", auditItems); - }, - }; - - return { - auditDefinition, - filterOptions, - initialSorting, - paginationOptions, - getRows, - downloadCsv, - }; -} - -export function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "registerId" : sorting.field, - direction: sorting.order, - }; -} - -function getFilterOptions(selectablePeriods: string[]): FilterOptions { - return { - periods: selectablePeriods, - }; -} diff --git a/src/webapp/reports/csy-summary-mortality/CSYSummaryReport.tsx b/src/webapp/reports/csy-summary-mortality/CSYSummaryReport.tsx deleted file mode 100644 index f950c2c..0000000 --- a/src/webapp/reports/csy-summary-mortality/CSYSummaryReport.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import { CSYSummaryList } from "./csy-summary-mortality-list/CSYSummaryList"; - -const CSYSummaryReportMortality: React.FC = () => { - const classes = useStyles(); - - return ( -
- - {i18n.t("CSY Summary Report - Mortality by Injury Severity")} - - - -
- ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default CSYSummaryReportMortality; diff --git a/src/webapp/reports/csy-summary-mortality/SummaryViewModel.ts b/src/webapp/reports/csy-summary-mortality/SummaryViewModel.ts deleted file mode 100644 index 933c5b7..0000000 --- a/src/webapp/reports/csy-summary-mortality/SummaryViewModel.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { SummaryItem } from "../../../domain/reports/csy-summary-mortality/entities/SummaryItem"; - -export interface SummaryViewModel { - id: string; - scoringSystem: string; - severity: string; - mortality: any; - total: string; -} - -export function getSummaryViews(items: SummaryItem[]): SummaryViewModel[] { - return items.map(item => { - return { - id: `${item.scoringSystem}-${item.severity}`, - scoringSystem: item.scoringSystem, - severity: item.severity, - mortality: item.mortality, - total: item.total, - }; - }); -} diff --git a/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/CSYSummaryList.tsx b/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/CSYSummaryList.tsx deleted file mode 100644 index a8c78ac..0000000 --- a/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/CSYSummaryList.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { ObjectsList, TableConfig, useObjectsTable } from "@eyeseetea/d2-ui-components"; -import React, { useMemo, useState } from "react"; -import styled from "styled-components"; -import { SummaryViewModel } from "../SummaryViewModel"; -import i18n from "../../../../locales"; -import { Filter, Filters } from "./Filters"; -import { useSummaryReport } from "./useSummaryReport"; - -export const CSYSummaryList: React.FC = React.memo(() => { - const [filters, setFilters] = useState(() => getEmptyDataValuesFilter()); - - const { downloadCsv, filterOptions, initialSorting, paginationOptions, getRows } = useSummaryReport(filters); - - const baseConfig: TableConfig = useMemo( - () => ({ - columns: [ - { name: "scoringSystem", text: i18n.t("Scoring System"), sortable: true }, - { name: "severity", text: i18n.t("Severity"), sortable: true }, - { name: "mortality", text: i18n.t("Mortality"), sortable: true }, - { name: "total", text: i18n.t("Total"), sortable: true }, - ], - actions: [], - initialSorting: initialSorting, - paginationOptions: paginationOptions, - }), - [initialSorting, paginationOptions] - ); - const tableProps = useObjectsTable(baseConfig, getRows); - - return ( - <> - {...tableProps} onChangeSearch={undefined} globalActions={[downloadCsv]}> - - - -

*Percentage values are displayed as the percent of total registry cases during period.

-

References:

- - {scoringSystemReferences.map(({ scoringSystem, reference }) => { - return ( -
  • - {scoringSystem} - {} -
  • - ); - })} -
    -
    - - ); -}); - -function getEmptyDataValuesFilter(): Filter { - return { - summaryType: "mortalityInjurySeverity", - orgUnitPaths: [], - year: (new Date().getFullYear() - 1).toString(), - periodType: "yearly", - quarter: undefined, - }; -} - -const scoringSystemReferences = [ - { - scoringSystem: "GAP", - reference: - "https://www.jaypeejournals.com/eJournals/ShowText.aspx?ID=12905&Type=FREE&TYP=TOP&IN=~/eJournals/images/JPLOGO.gif&IID=1004&isPDF=YES", - }, - { - scoringSystem: "MGAP", - reference: - "https://www.jaypeejournals.com/eJournals/ShowText.aspx?ID=12905&Type=FREE&TYP=TOP&IN=~/eJournals/images/JPLOGO.gif&IID=1004&isPDF=YES", - }, - { - scoringSystem: "KTS", - reference: "https://www.researchgate.net/publication/27799668_Kampala_Trauma_Score_KTS_is_it_a_new_triage_tool", - }, - { - scoringSystem: "RTS", - reference: - "https://www.jaypeejournals.com/eJournals/ShowText.aspx?ID=12905&Type=FREE&TYP=TOP&IN=~/eJournals/images/JPLOGO.gif&IID=1004&isPDF=YES", - }, -]; - -const Container = styled.div` - line-height: 10px; - margin-bottom: 0; -`; - -const ReferenceList = styled.ul` - list-style-type: none; - margin: 0; - padding: 0; - text-decoration: none; - display: flex; - gap: 8px; - color: #0099de; -`; diff --git a/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/Filters.tsx b/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/Filters.tsx deleted file mode 100644 index 30f4574..0000000 --- a/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/Filters.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useMemo, useState } from "react"; -import { useAppContext } from "../../../contexts/app-context"; -import { Id } from "../../../../domain/common/entities/Base"; -import React from "react"; -import { OrgUnitsFilterButton } from "../../../components/org-units-filter/OrgUnitsFilterButton"; -import styled from "styled-components"; -import { Dropdown, DropdownProps } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import _ from "lodash"; -import { SummaryType } from "../../../../domain/reports/csy-summary-mortality/entities/SummaryItem"; - -export interface FiltersProps { - values: Filter; - options: FilterOptions; - onChange: React.Dispatch>; -} - -export interface Filter { - summaryType: SummaryType; - orgUnitPaths: Id[]; - periodType: string; - year: string; - quarter?: string; -} - -export interface FilterOptions { - periods: string[]; -} - -const summaryTypeItems = [ - { - value: "mortalityInjurySeverity", - text: i18n.t("Mortality Injury Severity"), - }, -]; - -export const Filters: React.FC = React.memo(props => { - const { config, api } = useAppContext(); - const { values: filter, options: filterOptions, onChange } = props; - - const [periodType, setPerType] = useState("yearly"); - - const rootIds = React.useMemo( - () => - _(config.currentUser.orgUnits) - .map(ou => ou.id) - .value(), - [config] - ); - - const periodTypeItems = React.useMemo(() => { - return [ - { value: "yearly", text: i18n.t("Yearly") }, - { value: "quarterly", text: i18n.t("Quarterly") }, - ]; - }, []); - - const yearItems = useMemoOptionsFromStrings(filterOptions.periods); - - const quarterPeriodItems = React.useMemo(() => { - return [ - { value: "Q1", text: i18n.t("Jan - March") }, - { value: "Q2", text: i18n.t("April - June") }, - { value: "Q3", text: i18n.t("July - September") }, - { value: "Q4", text: i18n.t("October - December") }, - ]; - }, []); - - const setSummaryType = React.useCallback( - summaryType => { - onChange(filter => ({ ...filter, summaryType: summaryType as SummaryType })); - }, - [onChange] - ); - - const setQuarterPeriod = React.useCallback( - quarterPeriod => { - onChange(filter => ({ ...filter, quarter: quarterPeriod ?? "" })); - }, - [onChange] - ); - - const setYear = React.useCallback( - year => { - onChange(filter => ({ ...filter, year: year ?? "" })); - }, - [onChange] - ); - - const setPeriodType = React.useCallback( - periodType => { - setPerType(periodType ?? "yearly"); - setQuarterPeriod(periodType !== "yearly" ? "Q1" : undefined); - - onChange(filter => ({ ...filter, periodType: periodType ?? "yearly" })); - }, - [onChange, setQuarterPeriod] - ); - - return ( - - - - onChange({ ...filter, orgUnitPaths: paths })} - selectableLevels={[1, 2, 3, 4, 5, 6, 7]} - /> - - - - - - {periodType === "quarterly" && ( - <> - - - )} - - ); -}); - -function useMemoOptionsFromStrings(options: string[]) { - return useMemo(() => { - return options.map(option => ({ value: option, text: option })); - }, [options]); -} - -const Container = styled.div` - display: flex; - gap: 1rem; - flex-wrap: wrap; -`; - -const SummaryTypeDropdown = styled(Dropdown)` - margin-left: -10px; - width: 200px; -`; - -const SingleDropdownStyled = styled(Dropdown)` - margin-left: -10px; - width: 180px; -`; - -type SingleDropdownHandler = DropdownProps["onChange"]; diff --git a/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/useSummaryReport.tsx b/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/useSummaryReport.tsx deleted file mode 100644 index c5e943a..0000000 --- a/src/webapp/reports/csy-summary-mortality/csy-summary-mortality-list/useSummaryReport.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { useMemo, useState } from "react"; -import { useAppContext } from "../../../contexts/app-context"; -import { useReload } from "../../../utils/use-reload"; -import { TableGlobalAction, TablePagination, TableSorting, useSnackbar } from "@eyeseetea/d2-ui-components"; -import { getSummaryViews, SummaryViewModel } from "../SummaryViewModel"; -import { emptyPage, PaginatedObjects, Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import { SummaryItem } from "../../../../domain/reports/csy-summary-mortality/entities/SummaryItem"; -import { Filter, FilterOptions } from "./Filters"; -import StorageIcon from "@material-ui/icons/Storage"; -import _ from "lodash"; - -interface SummaryReportState { - downloadCsv: TableGlobalAction; - filterOptions: FilterOptions; - initialSorting: TableSorting; - paginationOptions: { - pageSizeOptions: number[]; - pageSizeInitialValue: number; - }; - getRows: ( - search: string, - paging: TablePagination, - sorting: TableSorting - ) => Promise>; -} - -const initialSorting = { - field: "scoringSystem" as const, - order: "asc" as const, -}; - -const paginationOptions = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, -}; - -export function useSummaryReport(filters: Filter): SummaryReportState { - const { compositionRoot } = useAppContext(); - - const [reloadKey, _reload] = useReload(); - const snackbar = useSnackbar(); - const [sorting, setSorting] = useState>(); - - const selectablePeriods = useMemo(() => { - const currentYear = new Date().getFullYear(); - return _.range(currentYear - 10, currentYear + 1).map(year => year.toString()); - }, []); - const filterOptions = useMemo(() => getFilterOptions(selectablePeriods), [selectablePeriods]); - - const getRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects } = await compositionRoot.summaryMortality - .get({ - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }) - .catch(error => { - snackbar.error(error.message); - return emptyPage; - }); - - setSorting(sorting); - console.debug("Reloading", reloadKey); - return { pager, objects: getSummaryViews(objects) }; - }, - [compositionRoot.summaryMortality, filters, reloadKey, snackbar] - ); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - const { objects: summaryItems } = await compositionRoot.summaryMortality.get({ - paging: { page: 1, pageSize: 100000 }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }); - - compositionRoot.summaryMortality.save("summary-table-report.csv", summaryItems); - }, - }; - - return { - downloadCsv, - filterOptions, - initialSorting, - paginationOptions, - getRows, - }; -} - -function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "scoringSystem" : sorting.field, - direction: sorting.order, - }; -} - -function getFilterOptions(selectablePeriods: string[]): FilterOptions { - return { - periods: selectablePeriods, - }; -} diff --git a/src/webapp/reports/csy-summary-patient/CSYSummaryReport.tsx b/src/webapp/reports/csy-summary-patient/CSYSummaryReport.tsx deleted file mode 100644 index ed01789..0000000 --- a/src/webapp/reports/csy-summary-patient/CSYSummaryReport.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import React from "react"; -import { CSYSummaryList } from "./csy-summary-list/CSYSummaryList"; - -const CSYSummaryReport: React.FC = () => { - const classes = useStyles(); - - return ( -
    - - {i18n.t("CSY Summary Report - Patient Characteristics")} - - - -
    - ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default CSYSummaryReport; diff --git a/src/webapp/reports/csy-summary-patient/SummaryViewModel.ts b/src/webapp/reports/csy-summary-patient/SummaryViewModel.ts deleted file mode 100644 index 6d0cc27..0000000 --- a/src/webapp/reports/csy-summary-patient/SummaryViewModel.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { SummaryItem } from "../../../domain/reports/csy-summary-patient/entities/SummaryItem"; - -export interface SummaryViewModel { - id: string; - group: string; - subGroup: string; - yearLessThan1: string; - year1To4: string; - year5To9: string; - year10To14: string; - year15To19: string; - year20To40: string; - year40To60: string; - year60To80: string; - yearGreaterThan80: string; - unknown: string; - total: string; -} - -export function getSummaryViews(items: SummaryItem[]): SummaryViewModel[] { - return items.map((item, i) => { - return { - id: i.toString(), - group: item.group, - subGroup: item.subGroup, - yearLessThan1: item.yearLessThan1, - year1To4: item.year1To4, - year5To9: item.year5To9, - year10To14: item.year10To14, - year15To19: item.year15To19, - year20To40: item.year20To40, - year40To60: item.year40To60, - year60To80: item.year60To80, - yearGreaterThan80: item.yearGreaterThan80, - unknown: item.unknown, - total: item.total, - }; - }); -} diff --git a/src/webapp/reports/csy-summary-patient/csy-summary-list/CSYSummaryList.tsx b/src/webapp/reports/csy-summary-patient/csy-summary-list/CSYSummaryList.tsx deleted file mode 100644 index a9cd6f5..0000000 --- a/src/webapp/reports/csy-summary-patient/csy-summary-list/CSYSummaryList.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { ObjectsList, TableConfig, useObjectsTable } from "@eyeseetea/d2-ui-components"; -import React, { useMemo, useState } from "react"; -import { SummaryViewModel } from "../SummaryViewModel"; -import i18n from "../../../../locales"; -import { Filter, Filters } from "./Filters"; -import { useSummaryReport } from "./useSummaryReport"; - -export const CSYSummaryList: React.FC = React.memo(() => { - const [filters, setFilters] = useState(() => getEmptyDataValuesFilter()); - const { downloadCsv, filterOptions, initialSorting, paginationOptions, getRows } = useSummaryReport(filters); - - const baseConfig: TableConfig = useMemo( - () => ({ - columns: [ - { name: "group", text: i18n.t("Group"), sortable: false }, - { name: "subGroup", text: i18n.t("Sub-Group"), sortable: true }, - { name: "yearLessThan1", text: i18n.t("< 1 yr"), sortable: true }, - { name: "year1To4", text: i18n.t("1 - 4 yr"), sortable: true }, - { name: "year5To9", text: i18n.t("5 - 9 yr"), sortable: true }, - { name: "year10To14", text: i18n.t("10 - 14 yr"), sortable: true }, - { name: "year15To19", text: i18n.t("15 - 19 yr"), sortable: true }, - { name: "year20To40", text: i18n.t("20 - 40 yr"), sortable: true }, - { name: "year40To60", text: i18n.t("40 - 60 yr"), sortable: true }, - { name: "year60To80", text: i18n.t("60 - 80 yr"), sortable: true }, - { name: "yearGreaterThan80", text: i18n.t("80+ yr"), sortable: true }, - { name: "unknown", text: i18n.t("Unknown"), sortable: true }, - { name: "total", text: i18n.t("Total"), sortable: true }, - ], - actions: [], - initialSorting: initialSorting, - paginationOptions: paginationOptions, - }), - [initialSorting, paginationOptions] - ); - - const tableProps = useObjectsTable(baseConfig, getRows); - - return ( - {...tableProps} onChangeSearch={undefined} globalActions={[downloadCsv]}> - - - ); -}); - -function getEmptyDataValuesFilter(): Filter { - return { - summaryType: "patientCharacteristics", - orgUnitPaths: [], - year: (new Date().getFullYear() - 1).toString(), - periodType: "yearly", - quarter: undefined, - }; -} diff --git a/src/webapp/reports/csy-summary-patient/csy-summary-list/Filters.tsx b/src/webapp/reports/csy-summary-patient/csy-summary-list/Filters.tsx deleted file mode 100644 index 9d21fb6..0000000 --- a/src/webapp/reports/csy-summary-patient/csy-summary-list/Filters.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { useMemo, useState } from "react"; -import { useAppContext } from "../../../contexts/app-context"; -import { Id } from "../../../../domain/common/entities/Base"; -import React from "react"; -import { OrgUnitsFilterButton } from "../../../components/org-units-filter/OrgUnitsFilterButton"; -import styled from "styled-components"; -import { Dropdown, DropdownProps } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../locales"; -import _ from "lodash"; -import { SummaryType } from "../../../../domain/reports/csy-summary-patient/entities/SummaryItem"; - -export interface FiltersProps { - values: Filter; - options: FilterOptions; - onChange: React.Dispatch>; -} - -export interface Filter { - summaryType: SummaryType; - orgUnitPaths: Id[]; - periodType: string; - year: string; - quarter?: string; -} - -export interface FilterOptions { - periods: string[]; -} - -export const summaryTypeItems = [ - { - value: "patientCharacteristics", - text: i18n.t("Patient Characteristics"), - }, -]; - -export const Filters: React.FC = React.memo(props => { - const { config, api } = useAppContext(); - const { values: filter, options: filterOptions, onChange } = props; - - const [periodType, setPerType] = useState("yearly"); - - const rootIds = React.useMemo( - () => - _(config.currentUser.orgUnits) - .map(ou => ou.id) - .value(), - [config] - ); - - const periodTypeItems = React.useMemo(() => { - return [ - { value: "yearly", text: i18n.t("Yearly") }, - { value: "quarterly", text: i18n.t("Quarterly") }, - ]; - }, []); - - const yearItems = useMemoOptionsFromStrings(filterOptions.periods); - - const quarterPeriodItems = React.useMemo(() => { - return [ - { value: "Q1", text: i18n.t("Jan - March") }, - { value: "Q2", text: i18n.t("April - June") }, - { value: "Q3", text: i18n.t("July - September") }, - { value: "Q4", text: i18n.t("October - December") }, - ]; - }, []); - - const setSummaryType = React.useCallback( - summaryType => { - onChange(filter => ({ ...filter, summaryType: summaryType as SummaryType })); - }, - [onChange] - ); - - const setQuarterPeriod = React.useCallback( - quarterPeriod => { - onChange(filter => ({ ...filter, quarter: quarterPeriod ?? "" })); - }, - [onChange] - ); - - const setYear = React.useCallback( - year => { - onChange(filter => ({ ...filter, year: year ?? "" })); - }, - [onChange] - ); - - const setPeriodType = React.useCallback( - periodType => { - setPerType(periodType ?? "yearly"); - setQuarterPeriod(periodType !== "yearly" ? "Q1" : undefined); - - onChange(filter => ({ ...filter, periodType: periodType ?? "yearly" })); - }, - [onChange, setQuarterPeriod] - ); - - return ( - - - - onChange({ ...filter, orgUnitPaths: paths })} - selectableLevels={[1, 2, 3, 4, 5, 6, 7]} - /> - - - - - - {periodType === "quarterly" && ( - <> - - - )} - - ); -}); - -function useMemoOptionsFromStrings(options: string[]) { - return useMemo(() => { - return options.map(option => ({ value: option, text: option })); - }, [options]); -} - -const Container = styled.div` - display: flex; - gap: 1rem; - flex-wrap: wrap; -`; - -const SummaryTypeDropdown = styled(Dropdown)` - margin-left: -10px; - width: 200px; -`; - -const SingleDropdownStyled = styled(Dropdown)` - margin-left: -10px; - width: 180px; -`; - -type SingleDropdownHandler = DropdownProps["onChange"]; diff --git a/src/webapp/reports/csy-summary-patient/csy-summary-list/useSummaryReport.tsx b/src/webapp/reports/csy-summary-patient/csy-summary-list/useSummaryReport.tsx deleted file mode 100644 index 248c5d6..0000000 --- a/src/webapp/reports/csy-summary-patient/csy-summary-list/useSummaryReport.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { TableGlobalAction, TablePagination, TableSorting, useSnackbar } from "@eyeseetea/d2-ui-components"; -import { Filter, FilterOptions } from "./Filters"; -import { useMemo, useState } from "react"; -import { useReload } from "../../../utils/use-reload"; -import { useAppContext } from "../../../contexts/app-context"; -import { SummaryItem } from "../../../../domain/reports/csy-summary-patient/entities/SummaryItem"; -import { emptyPage, PaginatedObjects, Sorting } from "../../../../domain/common/entities/PaginatedObjects"; -import { getSummaryViews, SummaryViewModel } from "../SummaryViewModel"; -import StorageIcon from "@material-ui/icons/Storage"; -import _ from "lodash"; - -interface SummaryReportState { - downloadCsv: TableGlobalAction; - filterOptions: FilterOptions; - getRows: ( - search: string, - paging: TablePagination, - sorting: TableSorting - ) => Promise>; - initialSorting: TableSorting; - paginationOptions: { - pageSizeOptions: number[]; - pageSizeInitialValue: number; - }; -} - -const initialSorting = { - field: "group" as const, - order: "asc" as const, -}; - -const paginationOptions = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, -}; - -export function useSummaryReport(filters: Filter): SummaryReportState { - const { compositionRoot } = useAppContext(); - const snackbar = useSnackbar(); - const [reloadKey, _reload] = useReload(); - - const [sorting, setSorting] = useState>(); - - const selectablePeriods = useMemo(() => { - const currentYear = new Date().getFullYear(); - return _.range(currentYear - 10, currentYear + 1).map(n => n.toString()); - }, []); - - const getRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects } = await compositionRoot.summary.get({ - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }); - - setSorting(sorting); - console.debug("Reloading", reloadKey); - return { pager, objects: getSummaryViews(objects) }; - }, - [compositionRoot.summary, filters, reloadKey] - ); - - const filterOptions = useMemo(() => getFilterOptions(selectablePeriods), [selectablePeriods]); - - const downloadCsv: TableGlobalAction = { - name: "downloadCsv", - text: "Download CSV", - icon: , - onClick: async () => { - if (!sorting) return; - const { objects: summaryItems } = await compositionRoot.summary - .get({ - paging: { page: 1, pageSize: 100000 }, - sorting: getSortingFromTableSorting(sorting), - ...filters, - }) - .catch(error => { - snackbar.error(error.message); - return emptyPage; - }); - - compositionRoot.summary.save("summary-table-report.csv", summaryItems); - }, - }; - - return { - downloadCsv, - filterOptions, - getRows, - initialSorting, - paginationOptions, - }; -} - -function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "group" : sorting.field, - direction: sorting.order, - }; -} - -function getFilterOptions(selectablePeriods: string[]) { - return { - periods: selectablePeriods, - }; -} diff --git a/src/webapp/reports/data-quality/DataQualityList.tsx b/src/webapp/reports/data-quality/DataQualityList.tsx deleted file mode 100644 index 6533707..0000000 --- a/src/webapp/reports/data-quality/DataQualityList.tsx +++ /dev/null @@ -1,284 +0,0 @@ -import { - ObjectsList, - TableColumn, - TableConfig, - TablePagination, - TableSorting, - useObjectsTable, -} from "@eyeseetea/d2-ui-components"; -import { Button, Typography } from "@material-ui/core"; -import _ from "lodash"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Namespaces } from "../../../data/common/clients/storage/Namespaces"; -import { Sorting } from "../../../domain/common/entities/PaginatedObjects"; -import { IndicatorItem, ProgramIndicatorItem } from "../../../domain/reports/data-quality/entities/DataQualityItem"; -import i18n from "../../../locales"; -import { useAppContext } from "../../contexts/app-context"; -import { - IndicatorViewModel, - ProgramIndicatorViewModel, - getDataQualityIndicatorViews, - getDataQualityProgramIndicatorViews, -} from "./DataQualityViewModel"; -import { useBooleanState } from "../../utils/use-boolean"; -import ReloadWarningModal from "../../components/reload_warning/ReloadWarningModal"; - -export const DataQualityList: React.FC = React.memo(() => { - const { compositionRoot, config } = useAppContext(); - - const [visibleIndicatorColumns, setVisibleIndicatorColumns] = useState(); - const [visibleProgramIndicatorColumns, setVisibleProgramIndicatorColumns] = useState(); - const [isReloadModal, { enable: openReloadModal, disable: closeReloadModal }] = useBooleanState(false); - const [isReloading, { enable: confirmReload, disable: stopReloading }] = useBooleanState(false); - const [isLoading, { enable: startLoading, disable: stopLoading }] = useBooleanState(false); - - useEffect(() => { - compositionRoot.dataQuality.getColumns(Namespaces.INDICATOR_STATUS_USER_COLUMNS).then(columns => { - setVisibleIndicatorColumns(columns); - }); - compositionRoot.dataQuality.getColumns(Namespaces.PROGRAM_INDICATOR_STATUS_USER_COLUMNS).then(columns => { - setVisibleProgramIndicatorColumns(columns); - }); - }, [compositionRoot]); - - const loadValidationData = useCallback(async () => { - await compositionRoot.dataQuality.loadValidation(); - }, [compositionRoot.dataQuality]); - - const reloadValidationData = useCallback(async () => { - await compositionRoot.dataQuality.resetValidation(); - }, [compositionRoot.dataQuality]); - - useEffect(() => { - if (isReloading) { - closeReloadModal(); - reloadValidationData().then(() => stopReloading()); - } - }, [reloadValidationData, isReloading, stopReloading, closeReloadModal]); - - useEffect(() => { - startLoading(); - loadValidationData().then(() => stopLoading()); - }, [loadValidationData, startLoading, stopLoading]); - - const indicatorBaseConfig: TableConfig = useMemo( - () => ({ - columns: [ - { name: "id", text: i18n.t("Id"), sortable: true }, - { name: "name", text: i18n.t("Name"), sortable: true }, - { name: "user", text: i18n.t("Created By"), sortable: true }, - { name: "lastUpdated", text: i18n.t("Last Updated"), sortable: true }, - { name: "denominator", text: i18n.t("Denominator"), sortable: true }, - { - name: "denominatorResult", - text: i18n.t("Valid denominator"), - sortable: false, - getValue: row => (row.denominatorResult ? i18n.t("Valid") : i18n.t("Invalid")), - }, - { name: "numerator", text: i18n.t("Numerator"), sortable: true }, - { - name: "numeratorResult", - text: i18n.t("Valid Numerator"), - sortable: false, - getValue: row => (row.numeratorResult ? i18n.t("Valid") : i18n.t("Invalid")), - }, - ], - actions: [], - initialSorting: { - field: "id" as const, - order: "asc" as const, - }, - paginationOptions: { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, - }, - }), - [] - ); - - const programIndicatorBaseConfig: TableConfig = useMemo( - () => ({ - columns: [ - { name: "id", text: i18n.t("Id"), sortable: true }, - { name: "name", text: i18n.t("Name"), sortable: true }, - { name: "user", text: i18n.t("Created By"), sortable: true }, - { name: "lastUpdated", text: i18n.t("Last Updated"), sortable: true }, - { name: "expression", text: i18n.t("Expression"), sortable: true }, - { - name: "expressionResult", - text: i18n.t("Valid expression"), - sortable: false, - getValue: row => (row.expressionResult ? i18n.t("Valid") : i18n.t("Invalid")), - }, - { name: "filter", text: i18n.t("Filter"), sortable: true }, - { - name: "filterResult", - text: i18n.t("Valid filter"), - sortable: false, - getValue: row => - row.filterResult - ? i18n.t("Valid") - : row.filterResult === undefined - ? i18n.t("Empty") - : i18n.t("Invalid"), - }, - ], - actions: [], - initialSorting: { - field: "id" as const, - order: "asc" as const, - }, - paginationOptions: { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, - }, - }), - [] - ); - - const getIndicatorRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects } = await compositionRoot.dataQuality.getIndicators( - { - config, - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getIndicatorSortingFromTableSorting(sorting), - }, - Namespaces.DATA_QUALITY - ); - - console.debug("load: ", isLoading, "reload: ", isReloading); - return { - pager, - objects: getDataQualityIndicatorViews(config, objects), - }; - }, - [compositionRoot.dataQuality, config, isLoading, isReloading] - ); - - const getProgramIndicatorRows = useMemo( - () => async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { pager, objects } = await compositionRoot.dataQuality.getProgramIndicators( - { - config, - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getProgramIndicatorSortingFromTableSorting(sorting), - }, - Namespaces.DATA_QUALITY - ); - - console.debug("load: ", isLoading, "reload: ", isReloading); - return { - pager, - objects: getDataQualityProgramIndicatorViews(config, objects), - }; - }, - [compositionRoot.dataQuality, config, isLoading, isReloading] - ); - - const saveReorderedIndicatorColumns = useCallback( - async (columnKeys: Array) => { - if (!visibleIndicatorColumns) return; - - await compositionRoot.dataQuality.saveColumns(Namespaces.INDICATOR_STATUS_USER_COLUMNS, columnKeys); - }, - [compositionRoot, visibleIndicatorColumns] - ); - - const saveReorderedProgramIndicatorColumns = useCallback( - async (columnKeys: Array) => { - if (!visibleProgramIndicatorColumns) return; - - await compositionRoot.dataQuality.saveColumns(Namespaces.PROGRAM_INDICATOR_STATUS_USER_COLUMNS, columnKeys); - }, - [compositionRoot, visibleProgramIndicatorColumns] - ); - - const indicatorTableProps = useObjectsTable(indicatorBaseConfig, getIndicatorRows); - const programIndicatorTableProps = useObjectsTable(programIndicatorBaseConfig, getProgramIndicatorRows); - - const indicatorColumnsToShow = useMemo[]>(() => { - if (!visibleIndicatorColumns || _.isEmpty(visibleIndicatorColumns)) return indicatorTableProps.columns; - - const indexes = _(visibleIndicatorColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(indicatorTableProps.columns) - .map(column => ({ ...column, hidden: !visibleIndicatorColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [indicatorTableProps.columns, visibleIndicatorColumns]); - - const programIndicatorColumnsToShow = useMemo[]>(() => { - if (!visibleProgramIndicatorColumns || _.isEmpty(visibleProgramIndicatorColumns)) - return programIndicatorTableProps.columns; - - const indexes = _(visibleProgramIndicatorColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(programIndicatorTableProps.columns) - .map(column => ({ ...column, hidden: !visibleProgramIndicatorColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [programIndicatorTableProps.columns, visibleProgramIndicatorColumns]); - - return ( - - - - - - - {i18n.t("Indicators")} - - - - {...indicatorTableProps} - columns={indicatorColumnsToShow} - onChangeSearch={undefined} - onReorderColumns={saveReorderedIndicatorColumns} - isLoading={isLoading || isReloading} - /> - - - {i18n.t("Program Indicators")} - - - - {...programIndicatorTableProps} - columns={programIndicatorColumnsToShow} - onChangeSearch={undefined} - onReorderColumns={saveReorderedProgramIndicatorColumns} - isLoading={isLoading || isReloading} - /> - - ); -}); - -export function getIndicatorSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "name" : sorting.field, - direction: sorting.order, - }; -} - -export function getProgramIndicatorSortingFromTableSorting( - sorting: TableSorting -): Sorting { - return { - field: sorting.field === "id" ? "name" : sorting.field, - direction: sorting.order, - }; -} diff --git a/src/webapp/reports/data-quality/DataQualityReport.tsx b/src/webapp/reports/data-quality/DataQualityReport.tsx deleted file mode 100644 index 3069377..0000000 --- a/src/webapp/reports/data-quality/DataQualityReport.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import { DataQualityList } from "./DataQualityList"; - -const DataQualityReport: React.FC = () => { - const classes = useStyles(); - - return ( -
    - - {i18n.t("Data quality")} - - - -
    - ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default DataQualityReport; diff --git a/src/webapp/reports/data-quality/DataQualityViewModel.ts b/src/webapp/reports/data-quality/DataQualityViewModel.ts deleted file mode 100644 index 928049b..0000000 --- a/src/webapp/reports/data-quality/DataQualityViewModel.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Config } from "../../../domain/common/entities/Config"; -import { IndicatorItem, ProgramIndicatorItem } from "../../../domain/reports/data-quality/entities/DataQualityItem"; - -export interface IndicatorViewModel { - id: string; - lastUpdated: string; - name: string; - user: string; - metadataType: string; - denominator: string; - denominatorResult: boolean; - numerator: string; - numeratorResult: boolean; -} - -export interface ProgramIndicatorViewModel { - id: string; - lastUpdated: string; - name: string; - user: string; - metadataType: string; - expression: string; - expressionResult: boolean; - filter: string; - filterResult: boolean | undefined; -} - -export function getDataQualityIndicatorViews(_config: Config, items: IndicatorItem[]): IndicatorViewModel[] { - return items.map(item => { - return { - id: item.id, - denominator: item.denominator, - denominatorResult: item.denominatorResult, - lastUpdated: item.lastUpdated, - name: item.name, - numerator: item.numerator, - numeratorResult: item.numeratorResult, - user: item.user, - metadataType: item.metadataType, - }; - }); -} - -export function getDataQualityProgramIndicatorViews( - _config: Config, - items: ProgramIndicatorItem[] -): ProgramIndicatorViewModel[] { - return items.map(item => { - return { - id: item.id, - expression: item.expression, - expressionResult: item.expressionResult, - lastUpdated: item.lastUpdated, - name: item.name, - filter: item.filter, - filterResult: item.filterResult, - user: item.user, - metadataType: item.metadataType, - }; - }); -} diff --git a/src/webapp/reports/glass-admin/DataMaintenanceViewModel.ts b/src/webapp/reports/glass-admin/DataMaintenanceViewModel.ts deleted file mode 100644 index c4c2344..0000000 --- a/src/webapp/reports/glass-admin/DataMaintenanceViewModel.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - ATCItem, - Module, - Status, - getATCItemId, -} from "../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import { Id } from "../../../types/d2-api"; - -export interface DataMaintenanceViewModel { - id: Id; - fileName: string; - fileType: string; - module: Module; - orgUnit: string; - orgUnitName: string; - period: string; - status: Status; -} - -export interface ATCViewModel { - id: string; - currentVersion: boolean; - previousVersion: boolean; - uploadedDate: string; - version: string; - year: string; -} - -export function getATCViewModel(items: ATCItem[]): ATCViewModel[] { - return items.map(item => { - return { - id: getATCItemId(item), - previousVersion: item.previousVersion, - currentVersion: item.currentVersion, - uploadedDate: item.uploadedDate, - version: item.version, - year: item.year, - }; - }); -} - -export function getVersion(rows: ATCViewModel[], versionType: "currentVersion" | "previousVersion") { - const versionRow = rows.find(row => row[versionType]); - const version = `ATC ${versionRow?.year}-V${versionRow?.version}`; - - return version; -} diff --git a/src/webapp/reports/glass-admin/GLASSAdminReport.tsx b/src/webapp/reports/glass-admin/GLASSAdminReport.tsx deleted file mode 100644 index aa5bf12..0000000 --- a/src/webapp/reports/glass-admin/GLASSAdminReport.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import GLASSHeader from "../../components/headers/glass-data-submission/index"; -import React from "react"; -import { DataMaintenanceList } from "./glass-admin-list/DataMaintenanceList"; - -const GLASSAdminReport: React.FC = () => { - const classes = useStyles(); - - return ( - - -
    - - {i18n.t("GLASS Admin Maintenance Report")} - - - -
    -
    - ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default GLASSAdminReport; diff --git a/src/webapp/reports/glass-admin/glass-admin-list/DataMaintenanceList.tsx b/src/webapp/reports/glass-admin/glass-admin-list/DataMaintenanceList.tsx deleted file mode 100644 index 6353118..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/DataMaintenanceList.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useState } from "react"; -import { Filter, Filters } from "./amc-report/Filter"; -import { TabPanel } from "../../../components/tabs/TabPanel"; -import { TabHeader } from "../../../components/tabs/TabHeader"; -import LoadingScreen from "../../../components/loading-screen/LoadingScreen"; -import { ATCClassificationList } from "./atc-classification/ATCClassificationList"; -import { AMCReport } from "./amc-report/AMCReport"; -import { useFiles } from "./amc-report/useFiles"; -import { useGetModules } from "./amc-report/useGetModules"; -import { useAppContext } from "../../../contexts/app-context"; - -export const DataMaintenanceList: React.FC = React.memo(() => { - const { compositionRoot, config } = useAppContext(); - - const [tabIndex, setTabIndex] = useState(0); - const [filters, setFilters] = useState(() => getEmptyDataValuesFilter()); - const { isDeleteModalOpen } = useFiles(filters); - - const { userModules } = useGetModules(compositionRoot, config); - - const amcModule = userModules.find(module => module.name === AMC_MODULE)?.id; - const isAMCModule = filters.module === amcModule; - - const handleChange = (_event: React.ChangeEvent<{}>, newValue: number) => { - setTabIndex(newValue); - }; - - return ( - - - - {filters.module && ( - <> - {isAMCModule && } - - - - - - - - - - - - )} - - ); -}); - -const AMC_MODULE = "AMC"; -const amcReportTabs = ["AMC Report", "ATC Classification"]; - -function getEmptyDataValuesFilter(): Filter { - return { - module: undefined, - }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/AMCReport.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/AMCReport.tsx deleted file mode 100644 index 6d8256c..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/AMCReport.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useMemo } from "react"; -import styled from "styled-components"; -import { ObjectsList, TableColumn, TableConfig, useObjectsTable } from "@eyeseetea/d2-ui-components"; -import { useFiles } from "./useFiles"; -import { DataMaintenanceViewModel } from "../../DataMaintenanceViewModel"; -import i18n from "../../../../../locales"; -import { Delete } from "@material-ui/icons"; -import _ from "lodash"; -import { Filter } from "./Filter"; -import { Button } from "@material-ui/core"; - -export interface AMCReportProps { - filters: Filter; -} - -export const AMCReport: React.FC = React.memo(props => { - const { filters } = props; - const { getFiles, pagination, initialSorting, filesToDelete, deleteFiles, visibleColumns, saveReorderedColumns } = - useFiles(filters); - - const baseConfig: TableConfig = useMemo( - () => ({ - actions: [ - { - name: "delete", - text: i18n.t("Delete"), - icon: , - multiple: true, - onClick: async (selectedIds: string[]) => deleteFiles(selectedIds), - isActive: (rows: DataMaintenanceViewModel[]) => { - return _.every(rows, row => row.status !== "DELETED"); - }, - }, - ], - columns: [ - { name: "fileName", text: i18n.t("File"), sortable: true }, - { name: "orgUnitName", text: i18n.t("Country"), sortable: true }, - { name: "period", text: i18n.t("Year"), sortable: true }, - { - name: "status", - text: i18n.t("Status"), - sortable: true, - }, - ], - initialSorting: initialSorting, - paginationOptions: pagination, - }), - [deleteFiles, initialSorting, pagination] - ); - - const tableProps = useObjectsTable(baseConfig, getFiles); - - const columnsToShow = useMemo[]>(() => { - if (!visibleColumns || _.isEmpty(visibleColumns)) return tableProps.columns; - - const indexes = _(visibleColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(tableProps.columns) - .map(column => ({ ...column, hidden: !visibleColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [tableProps.columns, visibleColumns]); - - return ( - - - - - - - {...tableProps} - columns={columnsToShow} - onChangeSearch={undefined} - onReorderColumns={saveReorderedColumns} - /> - - ); -}); - -const StyledButtonContainer = styled.div` - display: flex; - justify-content: end; - gap: 1rem; -`; diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/FilesState.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/FilesState.tsx deleted file mode 100644 index 4cb70db..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/FilesState.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { TablePagination, TableSorting } from "@eyeseetea/d2-ui-components"; -import { GLASSDataMaintenanceItem } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import { DataMaintenanceViewModel } from "../../DataMaintenanceViewModel"; -import { PaginatedObjects } from "../../../../../domain/common/entities/PaginatedObjects"; - -export interface FilesState { - getFiles: ( - _search: string, - paging: TablePagination, - sorting: TableSorting - ) => Promise>; - pagination: { - pageSizeOptions: number[]; - pageSizeInitialValue: number; - }; - initialSorting: TableSorting; - isDeleteModalOpen: boolean; - filesToDelete: string[]; - deleteFiles(ids: string[]): void; - visibleColumns: string[] | undefined; - saveReorderedColumns: (columnKeys: Array) => Promise; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/Filter.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/Filter.tsx deleted file mode 100644 index 9b69505..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/Filter.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useCallback, useMemo } from "react"; -import { Module } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import styled from "styled-components"; -import { Dropdown, DropdownProps } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../../locales"; -import { NamedRef } from "../../../../../domain/common/entities/Base"; -import { useAppContext } from "../../../../contexts/app-context"; -import { useGetModules } from "./useGetModules"; - -export interface FiltersProps { - values: Filter; - onChange: React.Dispatch>; -} - -export interface Filter { - module: Module | undefined; -} - -export const Filters: React.FC = React.memo(props => { - const { compositionRoot, config } = useAppContext(); - const { userModules } = useGetModules(compositionRoot, config); - - const { values: filter, onChange } = props; - - const filterOptions = useMemo(() => getFilterOptions(userModules), [userModules]); - const moduleItems = useMemoOptionsFromNamedRef(filterOptions.modules); - - const setModule = useCallback( - module => { - onChange(filter => ({ ...filter, module: module as Module })); - }, - [onChange] - ); - - return ( - - - - ); -}); - -function getFilterOptions(userModules: NamedRef[]) { - return { - modules: userModules, - }; -} - -function useMemoOptionsFromNamedRef(options: NamedRef[]) { - return useMemo(() => { - return options.map(option => ({ value: option.id, text: option.name })); - }, [options]); -} - -const Container = styled.div` - display: flex; - gap: 1rem; - flex-wrap: wrap; -`; - -const SingleDropdownStyled = styled(Dropdown)` - margin-left: -10px; - width: 250px; -`; - -type SingleDropdownHandler = DropdownProps["onChange"]; diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useAMCListColumns.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useAMCListColumns.tsx deleted file mode 100644 index 30ff619..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useAMCListColumns.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { Namespaces } from "../../../../../data/common/clients/storage/Namespaces"; -import { DataMaintenanceViewModel } from "../../DataMaintenanceViewModel"; -import { CompositionRoot } from "../../../../../compositionRoot"; - -interface UseAMCColumnsState { - visibleColumns: string[] | undefined; - saveReorderedColumns: (columnKeys: Array) => Promise; -} - -export function useAMCListColumns(compositionRoot: CompositionRoot): UseAMCColumnsState { - const [visibleColumns, setVisibleColumns] = useState(); - - useEffect(() => { - compositionRoot.glassAdmin.getColumns(Namespaces.FILE_UPLOADS_USER_COLUMNS).then(columns => { - setVisibleColumns(columns); - }); - }, [compositionRoot.glassAdmin]); - - const saveReorderedColumns = useCallback( - async (columnKeys: Array) => { - if (!visibleColumns) return; - - await compositionRoot.glassAdmin.saveColumns(Namespaces.FILE_UPLOADS_USER_COLUMNS, columnKeys); - }, - [compositionRoot.glassAdmin, visibleColumns] - ); - - return { visibleColumns, saveReorderedColumns }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useDeleteFiles.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useDeleteFiles.tsx deleted file mode 100644 index 3caf275..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useDeleteFiles.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useCallback } from "react"; -import { CompositionRoot } from "../../../../../compositionRoot"; -import { Namespaces } from "../../../../../data/common/clients/storage/Namespaces"; -import { useBooleanState } from "../../../../utils/use-boolean"; - -interface DeleteFilesState { - isDeleteModalOpen: boolean; - deleteFiles(ids: string[]): void; -} - -export function useDeleteFiles(compositionRoot: CompositionRoot, reload: () => void): DeleteFilesState { - const [isDeleteModalOpen, { enable: openDeleteModal, disable: closeDeleteModal }] = useBooleanState(false); - - const deleteFiles = useCallback( - async (ids: string[]) => { - openDeleteModal(); - await compositionRoot.glassAdmin - .updateStatus(Namespaces.FILE_UPLOADS, "delete", ids) - ?.then(() => closeDeleteModal()); - reload(); - }, - [closeDeleteModal, compositionRoot.glassAdmin, openDeleteModal, reload] - ); - - return { deleteFiles, isDeleteModalOpen }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useFiles.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useFiles.tsx deleted file mode 100644 index a5492f0..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useFiles.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { FilesState } from "./FilesState"; -import { useAppContext } from "../../../../contexts/app-context"; -import { useReload } from "../../../../utils/use-reload"; -import { Filter } from "./Filter"; -import { useGetFiles } from "./useGetFiles"; -import { useDeleteFiles } from "./useDeleteFiles"; -import { useAMCListColumns } from "./useAMCListColumns"; - -const pagination = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, -}; - -const initialSorting = { - field: "fileName" as const, - order: "asc" as const, -}; - -export function useFiles(filters: Filter): FilesState { - const { compositionRoot } = useAppContext(); - const [reloadKey, reload] = useReload(); - - const { filesToDelete, getFiles } = useGetFiles(compositionRoot, filters, reloadKey); - const { deleteFiles, isDeleteModalOpen } = useDeleteFiles(compositionRoot, reload); - const { visibleColumns, saveReorderedColumns } = useAMCListColumns(compositionRoot); - - return { - getFiles, - pagination, - initialSorting, - isDeleteModalOpen, - filesToDelete, - deleteFiles, - visibleColumns, - saveReorderedColumns, - }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useGetFiles.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useGetFiles.tsx deleted file mode 100644 index e0be993..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useGetFiles.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useCallback, useState } from "react"; -import { TablePagination, TableSorting } from "@eyeseetea/d2-ui-components"; -import { PaginatedObjects, Sorting } from "../../../../../domain/common/entities/PaginatedObjects"; -import { Namespaces } from "../../../../../data/common/clients/storage/Namespaces"; -import { Filter } from "./Filter"; -import { DataMaintenanceViewModel } from "../../DataMaintenanceViewModel"; -import { GLASSDataMaintenanceItem } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import { CompositionRoot } from "../../../../../compositionRoot"; - -interface GetFilesState { - getFiles: ( - _search: string, - paging: TablePagination, - sorting: TableSorting - ) => Promise>; - filesToDelete: string[]; -} - -export function useGetFiles(compositionRoot: CompositionRoot, filters: Filter, reloadKey: string): GetFilesState { - const [filesToDelete, setFilesToDelete] = useState([]); - - const getFiles = useCallback( - async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { objects, pager, rowIds } = await compositionRoot.glassAdmin.get( - { - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - module: filters.module, - }, - Namespaces.FILE_UPLOADS - ); - - setFilesToDelete(rowIds); - console.debug("Reloading", reloadKey); - - return { objects, pager }; - }, - [compositionRoot.glassAdmin, filters.module, reloadKey] - ); - - return { filesToDelete, getFiles }; -} - -function getSortingFromTableSorting( - sorting: TableSorting -): Sorting { - return { - field: sorting.field === "id" ? "fileName" : sorting.field, - direction: sorting.order, - }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useGetModules.tsx b/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useGetModules.tsx deleted file mode 100644 index 69ad3ef..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/amc-report/useGetModules.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { useEffect, useState } from "react"; -import { CompositionRoot } from "../../../../../compositionRoot"; -import { Config } from "../../../../../domain/common/entities/Config"; -import { GLASSModule } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; - -interface GetModulesState { - userModules: GLASSModule[]; -} - -export function useGetModules(compositionRoot: CompositionRoot, config: Config): GetModulesState { - const [userModules, setUserModules] = useState([]); - - useEffect(() => { - compositionRoot.glassAdmin.getModules(config).then(modules => setUserModules(modules)); - }, [compositionRoot.glassAdmin, config]); - - return { userModules }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/ATCClassificationList.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/ATCClassificationList.tsx deleted file mode 100644 index 603a815..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/ATCClassificationList.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { Button, Chip } from "@material-ui/core"; -import React, { useMemo, useState } from "react"; -import i18n from "../../../../../locales"; -import { - ConfirmationDialog, - ObjectsList, - TableColumn, - TableConfig, - useObjectsTable, -} from "@eyeseetea/d2-ui-components"; -import { ATCViewModel, getVersion } from "../../DataMaintenanceViewModel"; -import { CloudUpload } from "@material-ui/icons"; -import { useATC } from "./useATC"; -import _ from "lodash"; -import styled from "styled-components"; -import { GLASSAdminDialog } from "./GLASSAdminDialog"; -import { - ATCItemIdentifier, - parseATCItemId, -} from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import { useATCUpload } from "./useATCUpload"; -import { useATCActions } from "./useATCActions"; - -export const ATCClassificationList: React.FC = React.memo(() => { - const { initialSorting, pagination, uploadedYears, visibleColumns, getATCs, reload, saveReorderedColumns } = - useATC(); - const { - isPatchModalOpen, - isUploadATCModalOpen, - isRecalculateLogicModalOpen, - closePatchModal, - closeUploadATCModal, - closeRecalculateLogicModal, - openPatchModal, - openUploadATCModal, - openRecalculateLogicModal, - } = useATCUpload(); - const { - isRecalculating, - isPatchingNewVersion, - isUploadingNewATC, - isRecalculated, - cancelRecalculation, - patchVersion, - saveRecalculationLogic, - uploadATCFile, - } = useATCActions(reload, closePatchModal, closeUploadATCModal, closeRecalculateLogicModal); - - const [isCurrentVersion, setCurrentVersion] = useState(false); - const [selectedItems, setSelectedItems] = useState([]); - - const baseConfig: TableConfig = useMemo( - () => ({ - actions: [ - { - name: "patch", - text: i18n.t("Patch"), - icon: , - onClick: async (selectedIds: string[]) => { - openPatchModal(); - const items = _.compact(selectedIds.map(item => parseATCItemId(item))); - if (items.length === 0) return; - - setSelectedItems(items); - - const isCurrentVersion = _(items) - .map(item => item.currentVersion) - .every(); - setCurrentVersion(isCurrentVersion); - }, - }, - ], - columns: [ - { - name: "currentVersion", - text: i18n.t(" "), - sortable: false, - getValue: row => row.currentVersion && , - }, - { name: "year", text: i18n.t("Year"), sortable: true }, - { name: "uploadedDate", text: i18n.t("Uploaded date"), sortable: true }, - ], - initialSorting: initialSorting, - paginationOptions: pagination, - }), - [initialSorting, openPatchModal, pagination] - ); - - const tableProps = useObjectsTable(baseConfig, getATCs); - const previousVersionExists = tableProps.rows.some(row => row.previousVersion); - - const columnsToShow = useMemo[]>(() => { - if (!visibleColumns || _.isEmpty(visibleColumns)) return tableProps.columns; - - const indexes = _(visibleColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(tableProps.columns) - .map(column => ({ ...column, hidden: !visibleColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [tableProps.columns, visibleColumns]); - - return ( - - - - - {isRecalculated && ( - <> - {" "} - - {i18n.t("Cancel recalculation")} - - - )} - - {isRecalculated === false && ( - - )} - - - - {...tableProps} - columns={columnsToShow} - onChangeSearch={undefined} - onReorderColumns={saveReorderedColumns} - /> - - - - - - - {previousVersionExists && ( -

    - {i18n.t("Last recalculation was done with {{previousVersion}}", { - previousVersion: getVersion(tableProps.rows, "previousVersion"), - })} -

    - )} -

    - {i18n.t("RECALCULATE all existing submissions with {{currentVersion}}", { - currentVersion: getVersion(tableProps.rows, "currentVersion"), - })} -

    -

    {i18n.t("There is no UNDO for this action")}

    -
    -
    - ); -}); - -const StyledButtonContainer = styled.div` - display: flex; - justify-content: end; - gap: 1rem; -`; - -const CancelButton = styled(Button)` - background-color: #f44336; - color: white; - &:hover { - background-color: #d32f2f; - } -`; diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/ATCState.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/ATCState.tsx deleted file mode 100644 index 000c8d0..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/ATCState.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { TablePagination, TableSorting } from "@eyeseetea/d2-ui-components"; -import { ATCViewModel } from "../../DataMaintenanceViewModel"; -import { PaginatedObjects } from "../../../../../domain/common/entities/PaginatedObjects"; - -export interface ATCState { - getATCs( - _search: string, - paging: TablePagination, - sorting: TableSorting - ): Promise>; - initialSorting: TableSorting; - pagination: { - pageSizeOptions: number[]; - pageSizeInitialValue: number; - }; - uploadedYears: string[]; - visibleColumns: string[] | undefined; - reload(): void; - saveReorderedColumns: (columnKeys: Array) => Promise; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/GLASSAdminDialog.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/GLASSAdminDialog.tsx deleted file mode 100644 index 296186b..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/GLASSAdminDialog.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { ConfirmationDialog, ConfirmationDialogProps } from "@eyeseetea/d2-ui-components"; -import React, { useCallback, useMemo, useState } from "react"; -import i18n from "../../../../../locales"; -import styled from "styled-components"; -import { - Input, - // @ts-ignore -} from "@dhis2/ui"; -import { CloudUpload } from "@material-ui/icons"; -import { ATCItemIdentifier } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; - -interface DialogProps extends ConfirmationDialogProps { - title: string; - description: string; - uploadedYears?: string[]; - selectedItems?: ATCItemIdentifier[]; - closeModal(): void; - saveFile(selectedFile: File | undefined, year: string, selectedItems?: ATCItemIdentifier[]): void; -} - -export const GLASSAdminDialog: React.FC = React.memo(props => { - const { description, isOpen, selectedItems, title, uploadedYears, disableSave, closeModal, saveFile } = props; - - const [period, setPeriod] = useState(""); - const [selectedFile, setSelectedFile] = useState(undefined); - - const disableModalSave = useMemo(() => { - return !!disableSave || !selectedFile || !!uploadedYears?.includes(period); - }, [disableSave, period, uploadedYears, selectedFile]); - - const handleFileChange = useCallback((event: React.ChangeEvent) => { - const files = event.target.files; - if (files && files.length > 0) { - setSelectedFile(files[0]); - } else { - setSelectedFile(undefined); - } - }, []); - - return ( - saveFile(selectedFile, period, selectedItems)} - onCancel={closeModal} - saveText={i18n.t("Continue")} - cancelText={i18n.t("Cancel")} - disableSave={disableModalSave} - maxWidth="md" - fullWidth - > -

    {i18n.t(description)}

    - - {uploadedYears && ( - { - setPeriod(value); - }} - placeholder="Enter year" - value={period} - disabled={false} - /> - )} - - - - {i18n.t("Select File")} - - - - {selectedFile &&

    {selectedFile.name}

    } -
    -
    - ); -}); - -const StyledInput = styled(Input)` - width: 20%; -`; - -const FileInputWrapper = styled.div` - position: relative; - margin-top: 1rem; - width: min-width; - display: flex; - gap: 0.5rem; -`; - -const FileInputLabel = styled.label` - display: flex; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 0.75rem 1rem; - cursor: pointer; - border-radius: 0.5rem; - background-color: #1976d2; - font-size: 1rem; - color: #fff; -`; - -const FileInput = styled.input` - display: none; -`; diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATC.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATC.tsx deleted file mode 100644 index 517446f..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATC.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useAppContext } from "../../../../contexts/app-context"; -import { useReload } from "../../../../utils/use-reload"; -import { ATCState } from "./ATCState"; -import { useATCListColumns } from "./useATCListColumns"; -import { useGetATCs } from "./useGetATCs"; - -const pagination = { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, -}; - -const initialSorting = { - field: "year" as const, - order: "desc" as const, -}; - -export function useATC(): ATCState { - const { compositionRoot } = useAppContext(); - const [reloadKey, reload] = useReload(); - - const { uploadedYears, getATCs } = useGetATCs(compositionRoot, reloadKey); - const { visibleColumns, saveReorderedColumns } = useATCListColumns(compositionRoot); - - return { - initialSorting, - pagination, - uploadedYears, - visibleColumns, - getATCs, - reload, - saveReorderedColumns, - }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCActions.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCActions.tsx deleted file mode 100644 index 470954f..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCActions.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { ATCItemIdentifier } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import { useAppContext } from "../../../../contexts/app-context"; -import { Namespaces } from "../../../../../data/common/clients/storage/Namespaces"; -import { useSnackbar } from "@eyeseetea/d2-ui-components"; -import i18n from "../../../../../locales"; -import { - GlassAtcVersionData, - getGlassAtcVersionData, -} from "../../../../../domain/reports/glass-admin/entities/GlassAtcVersionData"; -import { extractJsonDataFromZIP } from "./utils"; - -export function useATCActions( - reload: () => void, - closePatchModal: () => void, - closeUploadATCModal: () => void, - closeRecalculateLogicModal: () => void -) { - const { compositionRoot } = useAppContext(); - const snackbar = useSnackbar(); - - const [loggerProgram, setLoggerProgram] = useState(""); - const [isRecalculating, setIsRecalculating] = useState(false); - const [isUploadingNewATC, setIsUploadingNewATC] = useState(false); - const [isPatchingNewVersion, setIsPatchingNewVersion] = useState(false); - const [isRecalculated, setIsRecalculated] = useState(false); - - useEffect(() => { - compositionRoot.glassAdmin.getATCRecalculationLogic(Namespaces.AMC_RECALCULATION).then(recalculationLogic => { - if (recalculationLogic) { - setIsRecalculated(recalculationLogic.recalculate); - compositionRoot.glassAdmin - .getATCLoggerProgram(Namespaces.AMC_RECALCULATION, recalculationLogic) - .then(setLoggerProgram); - } - }); - }, [compositionRoot.glassAdmin]); - - const patchVersion = useCallback( - async (selectedFile: File | undefined, period: string, selectedItems: ATCItemIdentifier[]) => { - try { - setIsPatchingNewVersion(true); - if (selectedFile) { - const glassAtcVersionData = await getGlassAtcVersionDataToUpload(selectedFile); - - await compositionRoot.glassAdmin.uploadFile( - Namespaces.ATCS, - glassAtcVersionData, - period, - selectedItems - ); - snackbar.success(i18n.t("Version has been successfully patched")); - } - } catch (error) { - snackbar.error(i18n.t(`Error encountered when parsing version: ${error}`)); - } finally { - setIsPatchingNewVersion(false); - closePatchModal(); - reload(); - } - }, - [closePatchModal, compositionRoot.glassAdmin, reload, snackbar] - ); - - const uploadATCFile = useCallback( - async (selectedFile: File | undefined, period: string) => { - try { - setIsUploadingNewATC(true); - if (selectedFile) { - const glassAtcVersionData = await getGlassAtcVersionDataToUpload(selectedFile); - - await compositionRoot.glassAdmin.uploadFile(Namespaces.ATCS, glassAtcVersionData, period); - snackbar.success(i18n.t("Upload finished")); - } - } catch (error) { - snackbar.error(i18n.t(`Error uploading the file: ${error}`)); - } finally { - setIsUploadingNewATC(false); - closeUploadATCModal(); - reload(); - } - }, - [closeUploadATCModal, compositionRoot.glassAdmin, reload, snackbar] - ); - - const cancelRecalculation = useCallback(async () => { - await compositionRoot.glassAdmin - .cancelRecalculation(Namespaces.AMC_RECALCULATION) - .then(() => setIsRecalculated(false)); - reload(); - snackbar.success(i18n.t("Recalculation has been cancelled successfully")); - }, [compositionRoot.glassAdmin, reload, snackbar]); - - const saveRecalculationLogic = useCallback(async () => { - try { - setIsRecalculating(true); - await compositionRoot.glassAdmin.saveRecalculationLogic(Namespaces.AMC_RECALCULATION); - snackbar.success( - i18n.t("Please go to the program {{loggerProgram}} to see the logs of this recalculation", { - loggerProgram, - }) - ); - } catch (error) { - snackbar.error(i18n.t("Error when saving recalculation logic")); - } finally { - setIsRecalculating(false); - setIsRecalculated(true); - closeRecalculateLogicModal(); - reload(); - } - }, [closeRecalculateLogicModal, compositionRoot.glassAdmin, loggerProgram, reload, snackbar]); - - return { - isPatchingNewVersion, - isUploadingNewATC, - isRecalculating, - isRecalculated, - cancelRecalculation, - patchVersion, - saveRecalculationLogic, - uploadATCFile, - }; -} - -async function getGlassAtcVersionDataToUpload(file: File): Promise { - const jsonData = await extractJsonDataFromZIP(file); - return getGlassAtcVersionData(jsonData); -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCListColumns.ts b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCListColumns.ts deleted file mode 100644 index 2b31681..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCListColumns.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback, useEffect, useState } from "react"; -import { Namespaces } from "../../../../../data/common/clients/storage/Namespaces"; -import { ATCViewModel } from "../../DataMaintenanceViewModel"; -import { CompositionRoot } from "../../../../../compositionRoot"; - -interface UseATCColumnsState { - visibleColumns: string[] | undefined; - saveReorderedColumns: (columnKeys: Array) => Promise; -} - -export function useATCListColumns(compositionRoot: CompositionRoot): UseATCColumnsState { - const [visibleColumns, setVisibleColumns] = useState(); - - useEffect(() => { - compositionRoot.glassAdmin.getColumns(Namespaces.ATC_USER_COLUMNS).then(columns => { - setVisibleColumns(columns); - }); - }, [compositionRoot.glassAdmin]); - - const saveReorderedColumns = useCallback( - async (columnKeys: Array) => { - if (!visibleColumns) return; - - await compositionRoot.glassAdmin.saveColumns(Namespaces.ATC_USER_COLUMNS, columnKeys); - }, - [compositionRoot.glassAdmin, visibleColumns] - ); - - return { visibleColumns, saveReorderedColumns }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCUpload.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCUpload.tsx deleted file mode 100644 index 6c649be..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useATCUpload.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useBooleanState } from "../../../../utils/use-boolean"; - -export function useATCUpload() { - const [isPatchModalOpen, { enable: openPatchModal, disable: closePatchModal }] = useBooleanState(false); - const [isUploadATCModalOpen, { enable: openUploadATCModal, disable: closeUploadATCModal }] = useBooleanState(false); - const [isRecalculateLogicModalOpen, { enable: openRecalculateLogicModal, disable: closeRecalculateLogicModal }] = - useBooleanState(false); - - return { - isPatchModalOpen, - isUploadATCModalOpen, - isRecalculateLogicModalOpen, - closePatchModal, - closeUploadATCModal, - openPatchModal, - openUploadATCModal, - openRecalculateLogicModal, - closeRecalculateLogicModal, - }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useGetATCs.tsx b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useGetATCs.tsx deleted file mode 100644 index 4da935e..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/useGetATCs.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useCallback, useState } from "react"; -import { TablePagination, TableSorting } from "@eyeseetea/d2-ui-components"; -import { Sorting } from "../../../../../domain/common/entities/PaginatedObjects"; -import { ATCViewModel, getATCViewModel } from "../../DataMaintenanceViewModel"; -import { Namespaces } from "../../../../../data/common/clients/storage/Namespaces"; -import { ATCItem } from "../../../../../domain/reports/glass-admin/entities/GLASSDataMaintenanceItem"; -import { CompositionRoot } from "../../../../../compositionRoot"; - -export function useGetATCs(compositionRoot: CompositionRoot, reloadKey: string) { - const [uploadedYears, setUploadedYears] = useState([]); - - const getATCs = useCallback( - async (_search: string, paging: TablePagination, sorting: TableSorting) => { - const { objects, pager, uploadedYears } = await compositionRoot.glassAdmin.getATCs( - { - paging: { page: paging.page, pageSize: paging.pageSize }, - sorting: getSortingFromTableSorting(sorting), - }, - Namespaces.ATCS - ); - - setUploadedYears(uploadedYears); - console.debug("Reloading", reloadKey); - - return { objects: getATCViewModel(objects), pager: pager }; - }, - [compositionRoot.glassAdmin, reloadKey] - ); - - return { uploadedYears, getATCs }; -} - -function getSortingFromTableSorting(sorting: TableSorting): Sorting { - return { - field: sorting.field === "id" ? "year" : sorting.field, - direction: sorting.order, - }; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/utils.ts b/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/utils.ts deleted file mode 100644 index 870bd9f..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/atc-classification/utils.ts +++ /dev/null @@ -1,30 +0,0 @@ -import JSZip from "jszip"; - -export async function extractJsonDataFromZIP(file: File): Promise[]> { - const zip = new JSZip(); - const jsonPromises: Promise>[] = []; - const contents = await zip.loadAsync(file); - - contents.forEach((relativePath, file) => { - if (file.dir) { - return; - } - - // Check if the file has a .json extension - if (/\.(json)$/i.test(relativePath)) { - const jsonPromise = file.async("string").then(content => { - try { - return JSON.parse(content) as Record; - } catch (error) { - console.error(`Error parsing JSON from ${relativePath}: ${error}`); - throw error; - } - }); - - jsonPromises.push(jsonPromise); - } - }); - const jsons = await Promise.all(jsonPromises); - - return jsons; -} diff --git a/src/webapp/reports/glass-admin/glass-admin-list/glass-admin-dialog/GLASSAdminDialog.tsx b/src/webapp/reports/glass-admin/glass-admin-list/glass-admin-dialog/GLASSAdminDialog.tsx deleted file mode 100644 index 1722999..0000000 --- a/src/webapp/reports/glass-admin/glass-admin-list/glass-admin-dialog/GLASSAdminDialog.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { ConfirmationDialog, ConfirmationDialogProps } from "@eyeseetea/d2-ui-components"; -import React, { useState } from "react"; -import i18n from "../../../../../locales"; -import styled from "styled-components"; -import { - Input, - // @ts-ignore -} from "@dhis2/ui"; -import { CloudUpload } from "@material-ui/icons"; - -interface DialogProps extends ConfirmationDialogProps { - title: string; - description: string; - uploadedYears?: string[]; - closeModal(): void; -} - -export const GLASSAdminDialog: React.FC = React.memo(props => { - const { description, isOpen, title, uploadedYears, closeModal, onSave } = props; - const [selectedFile, setSelectedFile] = useState(undefined); - const [period, setPeriod] = useState(""); - - const isYearUploaded = !!uploadedYears?.includes(period); - const disableSave = !selectedFile || isYearUploaded; - - const handleFileChange = (event: React.ChangeEvent) => { - const files = event.target.files; - - if (files && files.length > 0) { - setSelectedFile(files[0]); - } else { - setSelectedFile(undefined); - } - }; - - return ( - -

    {i18n.t(description)}

    - - {uploadedYears && ( - { - setPeriod(value); - }} - placeholder="Enter year" - value={period} - disabled={false} - /> - )} - - - - Select File - - - - {selectedFile &&

    {selectedFile.name}

    } -
    -
    - ); -}); - -const StyledInput = styled(Input)` - width: 20%; -`; - -const FileInputWrapper = styled.div` - position: relative; - margin-top: 1rem; - width: min-width; - display: flex; - gap: 0.5rem; -`; - -const FileInputLabel = styled.label` - display: flex; - align-items: center; - justify-content: center; - gap: 1rem; - padding: 0.75rem 1rem; - cursor: pointer; - border-radius: 0.5rem; - background-color: #1976d2; - font-size: 1rem; - color: #fff; -`; - -const FileInput = styled.input` - display: none; -`; diff --git a/src/webapp/reports/glass-data-submission/DataSubmissionViewModel.ts b/src/webapp/reports/glass-data-submission/DataSubmissionViewModel.ts deleted file mode 100644 index 1c08b64..0000000 --- a/src/webapp/reports/glass-data-submission/DataSubmissionViewModel.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Config } from "../../../domain/common/entities/Config"; -import { - EARDataSubmissionItem, - GLASSDataSubmissionItem, - Module, - Status, - getDataSubmissionItemId, - getEARSubmissionItemId, -} from "../../../domain/reports/glass-data-submission/entities/GLASSDataSubmissionItem"; - -export interface DataSubmissionViewModel { - id: string; - orgUnit: string; - orgUnitName: string; - period: string; - status: Status; - module: string; - questionnaireCompleted: boolean; - dataSetsUploaded: string; - submissionStatus: string; -} - -export interface EARDataSubmissionViewModel { - creationDate: string; - id: string; - module: Module; - orgUnitId: string; - orgUnitName: string; - levelOfConfidentiality: "CONFIDENTIAL" | "NON-CONFIDENTIAL"; - submissionStatus: string; - status: Status; -} - -export function getDataSubmissionViews(_config: Config, items: GLASSDataSubmissionItem[]): DataSubmissionViewModel[] { - return items.map(item => { - return { - id: getDataSubmissionItemId(item), - orgUnit: item.orgUnit, - orgUnitName: item.orgUnitName, - period: item.period, - status: item.status, - module: item.module, - questionnaireCompleted: item.questionnaireCompleted, - dataSetsUploaded: item.dataSetsUploaded, - submissionStatus: item.submissionStatus, - dataSubmissionPeriod: item.dataSubmissionPeriod, - }; - }); -} - -export function getEARDataSubmissionViews( - _config: Config, - items: EARDataSubmissionItem[] -): EARDataSubmissionViewModel[] { - return items.map(item => { - return { - id: getEARSubmissionItemId(item), - orgUnitId: item.orgUnit.id, - orgUnitName: item.orgUnit.name, - creationDate: item.creationDate, - status: item.status, - submissionStatus: item.submissionStatus, - levelOfConfidentiality: item.levelOfConfidentiality, - module: item.module, - }; - }); -} diff --git a/src/webapp/reports/glass-data-submission/GLASSDataSubmissionReport.tsx b/src/webapp/reports/glass-data-submission/GLASSDataSubmissionReport.tsx deleted file mode 100644 index 49e7e68..0000000 --- a/src/webapp/reports/glass-data-submission/GLASSDataSubmissionReport.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Typography, makeStyles } from "@material-ui/core"; -import i18n from "../../../locales"; -import { DataSubmissionList } from "./glass-data-submission-list/DataSubmissionList"; -import GLASSHeader from "../../components/headers/glass-data-submission/index"; -import React from "react"; - -const GLASSDataSubmissionReport: React.FC = () => { - const classes = useStyles(); - - return ( - - -
    - - {i18n.t("GLASS Data Submission Report")} - - - -
    -
    - ); -}; - -const useStyles = makeStyles({ - wrapper: { padding: 20 }, -}); - -export default GLASSDataSubmissionReport; diff --git a/src/webapp/reports/glass-data-submission/glass-data-submission-list/DataSubmissionList.tsx b/src/webapp/reports/glass-data-submission/glass-data-submission-list/DataSubmissionList.tsx deleted file mode 100644 index ea3be47..0000000 --- a/src/webapp/reports/glass-data-submission/glass-data-submission-list/DataSubmissionList.tsx +++ /dev/null @@ -1,358 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { DataSubmissionViewModel, EARDataSubmissionViewModel } from "../DataSubmissionViewModel"; -import { - ConfirmationDialog, - ObjectsList, - TableColumn, - TableConfig, - useObjectsTable, - useSnackbar, -} from "@eyeseetea/d2-ui-components"; -import { - TextArea, - // @ts-ignore -} from "@dhis2/ui"; -import i18n from "../../../../locales"; -import { useAppContext } from "../../../contexts/app-context"; -import { - parseDataSubmissionItemId, - parseEARSubmissionItemId, -} from "../../../../domain/reports/glass-data-submission/entities/GLASSDataSubmissionItem"; -import { Namespaces } from "../../../../data/common/clients/storage/Namespaces"; -import _ from "lodash"; -import { emptySubmissionFilter, Filters } from "./Filters"; -import { Check, Dashboard, LockOpen, ThumbDown, ThumbUp } from "@material-ui/icons"; -import { goToDhis2Url } from "../../../../utils/utils"; -import { useDataSubmissionList } from "./useDataSubmissionList"; -import { useDataSubmissionActions } from "./useDataSubmissionActions"; - -export const DataSubmissionList: React.FC = React.memo(() => { - const { api, compositionRoot } = useAppContext(); - - const snackbar = useSnackbar(); - const [filters, setFilters] = useState(emptySubmissionFilter); - const [isDatasetUpdate, setDatasetUpdate] = useState(false); - - const { - dataSubmissionPeriod, - initialSorting, - isEARModule, - isEGASPUser, - pagination, - moduleQuestionnaires, - selectablePeriods, - visibleColumns, - visibleEARColumns, - getEARRows, - getRows, - reload, - saveReorderedColumns, - saveReorderedEARColumns, - } = useDataSubmissionList(filters); - - const { - disableSave, - isRejectionDialogOpen, - loading, - rejectionReason, - saveText, - snackbarMessage, - approveEARSignal, - closeRejectionDialog, - onChangeRejectionReason, - openDataSubmissionRejectionDialog, - openEARSignalRejectionDialog, - rejectDataSubmission, - rejectEARSignal, - updateDataSubmissionStatus, - } = useDataSubmissionActions(isDatasetUpdate, reload); - - useEffect(() => { - if (snackbarMessage) { - snackbar[snackbarMessage.type](snackbarMessage.message); - } - }, [snackbar, snackbarMessage]); - - const baseTableColumns: TableColumn[] = useMemo( - () => [ - { name: "orgUnitName", text: i18n.t("Country"), sortable: true }, - { name: "period", text: i18n.t(isEGASPUser ? "Period" : "Year"), sortable: true }, - { - name: "questionnaireCompleted", - text: i18n.t("Questionnaire completed"), - sortable: true, - getValue: (row: DataSubmissionViewModel) => - row.questionnaireCompleted ? "Completed" : "Not completed", - }, - { - name: "dataSetsUploaded", - text: i18n.t("DataSets uploaded"), - sortable: true, - }, - { - name: "submissionStatus", - text: i18n.t("Status"), - sortable: true, - }, - ], - [isEGASPUser] - ); - - const baseConfig: TableConfig = useMemo( - () => ({ - columns: - !_.isEmpty(moduleQuestionnaires) || !filters.module - ? baseTableColumns - : baseTableColumns.filter(column => column.name !== "questionnaireCompleted"), - actions: [ - { - name: "unapvdDashboard", - text: i18n.t("Go to GLASS Unapproved Dashboard"), - icon: , - multiple: true, - onClick: async (selectedIds: string[]) => { - const items = _.compact(selectedIds.map(item => parseDataSubmissionItemId(item))); - if (items.length === 0) return; - - const unapvdDashboardId = await compositionRoot.glassDataSubmission.updateStatus( - Namespaces.DATA_SUBMISSSIONS, - "unapvdDashboard", - items - ); - - goToDhis2Url(api.baseUrl, `/dhis-web-dashboard/index.html#/${unapvdDashboardId}`); - }, - isActive: (rows: DataSubmissionViewModel[]) => { - return _.every(rows, row => row.status === "PENDING_APPROVAL"); - }, - }, - { - name: "approve", - text: i18n.t("Approve"), - icon: , - multiple: true, - onClick: async (selectedIds: string[]) => - updateDataSubmissionStatus("approve", selectedIds, Namespaces.DATA_SUBMISSSIONS), - isActive: (rows: DataSubmissionViewModel[]) => { - return _.every(rows, row => row.status === "PENDING_APPROVAL"); - }, - }, - { - name: "accept", - text: i18n.t("Accept"), - icon: , - multiple: true, - onClick: async (selectedIds: string[]) => - updateDataSubmissionStatus("accept", selectedIds, Namespaces.DATA_SUBMISSSIONS), - isActive: (rows: DataSubmissionViewModel[]) => { - return _.every(rows, row => row.status === "PENDING_UPDATE_APPROVAL"); - }, - }, - { - name: "reject", - text: i18n.t("Reject"), - icon: , - multiple: true, - onClick: openDataSubmissionRejectionDialog, - isActive: (rows: DataSubmissionViewModel[]) => { - return _.every(rows, row => { - setDatasetUpdate(row.status === "PENDING_UPDATE_APPROVAL"); - - return row.status === "PENDING_APPROVAL" || row.status === "PENDING_UPDATE_APPROVAL"; - }); - }, - }, - { - name: "reopen", - text: i18n.t("Reopen Submission"), - icon: , - multiple: true, - onClick: async (selectedIds: string[]) => - updateDataSubmissionStatus("reopen", selectedIds, Namespaces.DATA_SUBMISSSIONS), - isActive: (rows: DataSubmissionViewModel[]) => { - return _.every(rows, row => row.status === "PENDING_APPROVAL"); - }, - }, - ], - initialSorting: initialSorting, - paginationOptions: pagination, - }), - [ - api.baseUrl, - baseTableColumns, - compositionRoot.glassDataSubmission, - filters.module, - initialSorting, - openDataSubmissionRejectionDialog, - pagination, - moduleQuestionnaires, - updateDataSubmissionStatus, - ] - ); - - const earBaseConfig: TableConfig = useMemo( - () => ({ - columns: [ - { name: "orgUnitName", text: i18n.t("Country"), sortable: true }, - { name: "creationDate", text: i18n.t("Creation Date"), sortable: true }, - { - name: "levelOfConfidentiality", - text: i18n.t("Level of Confidentiality"), - sortable: true, - getValue: row => - row.levelOfConfidentiality === "CONFIDENTIAL" ? "Confidential" : "Non-Confidential", - }, - { name: "submissionStatus", text: i18n.t("Status"), sortable: true }, - ], - actions: [ - { - name: "signalDashboard", - text: i18n.t("Go to Signal"), - icon: , - multiple: false, - onClick: (selectedIds: string[]) => { - const items = _.compact(selectedIds.map(item => parseEARSubmissionItemId(item))); - if (items.length === 0) return; - - const signals = items.map(item => { - return { - orgUnit: item.orgUnit, - module: item.module, - id: item.id, - }; - }); - - goToDhis2Url( - api.baseUrl, - `api/apps/glass/index.html#/signal?orgUnit=${signals[0]?.orgUnit}&period=${signals[0]?.module}&eventId=${signals[0]?.id}` - ); - }, - }, - { - name: "approve", - text: i18n.t("Approve"), - icon: , - multiple: true, - onClick: async (selectedIds: string[]) => approveEARSignal(selectedIds), - isActive: (rows: EARDataSubmissionViewModel[]) => { - return _.every(rows, row => row.status === "PENDING_APPROVAL"); - }, - }, - { - name: "reject", - text: i18n.t("Reject"), - icon: , - multiple: true, - onClick: openEARSignalRejectionDialog, - isActive: (rows: EARDataSubmissionViewModel[]) => { - return _.every(rows, row => row.status === "PENDING_APPROVAL"); - }, - }, - ], - initialSorting: { - field: "orgUnitId" as const, - order: "asc" as const, - }, - paginationOptions: { - pageSizeOptions: [10, 20, 50], - pageSizeInitialValue: 10, - }, - }), - [api.baseUrl, approveEARSignal, openEARSignalRejectionDialog] - ); - - const tableProps = useObjectsTable(baseConfig, getRows); - const earTableProps = useObjectsTable(earBaseConfig, getEARRows); - - function getFilterOptions(selectablePeriods: string[]) { - return { - periods: selectablePeriods, - }; - } - const filterOptions = useMemo(() => getFilterOptions(selectablePeriods), [selectablePeriods]); - - const columnsToShow = useMemo[]>(() => { - if (!visibleColumns || _.isEmpty(visibleColumns)) return tableProps.columns; - - const indexes = _(visibleColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(tableProps.columns) - .map(column => ({ ...column, hidden: !visibleColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [tableProps.columns, visibleColumns]); - - const earColumnsToShow = useMemo[]>(() => { - if (!visibleEARColumns || _.isEmpty(visibleEARColumns)) return earTableProps.columns; - - const indexes = _(visibleEARColumns) - .map((columnName, idx) => [columnName, idx] as [string, number]) - .fromPairs() - .value(); - - return _(earTableProps.columns) - .map(column => ({ ...column, hidden: !visibleEARColumns.includes(column.name) })) - .sortBy(column => indexes[column.name] || 0) - .value(); - }, [earTableProps.columns, visibleEARColumns]); - - return isEARModule ? ( - - {...earTableProps} - loading={loading} - columns={earColumnsToShow} - onChangeSearch={undefined} - onReorderColumns={saveReorderedEARColumns} - > - - - rejectEARSignal()} - saveText={saveText} - maxWidth="md" - disableSave={disableSave} - fullWidth - > -

    {i18n.t("Please provide a reason for rejecting this signal:")}

    -