diff --git a/deploy/clone-pro-to-test.sh b/deploy/clone-pro-to-test.sh deleted file mode 100644 index 9e09c2a1..00000000 --- a/deploy/clone-pro-to-test.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -set -e -u -o pipefail - -# Actions: -# -# - In PRO: Create docker image and push to Harbor. -# - In TEST: Pull docker from Harbor and start. - -# Requirements: Open vendorlink (spintldhis01, stintldhis01) - -cd "$(dirname "$0")" -source "./lib.sh" - -run spintldhis01 bash push-pro-docker.sh -run stintldhis01 bash deploy-test-from-pro.sh diff --git a/deploy/clone-pro-to-training.sh b/deploy/clone-pro-to-training.sh deleted file mode 100644 index a6758271..00000000 --- a/deploy/clone-pro-to-training.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash -set -e -u -o pipefail - -# Actions: -# -# - Save last projects for 60 days in training instance. -# - Clone instance in proj-dhis-prod to localhost. -# - Setup of the local docker + recover training projects - -# Requirements: Open vendorlink before running the script: -# -# $ vendorlink sp-proj-dhis-test-00.clt1.theark.cloud sp-proj-dhis-prod-00.clt1.theark.cloud - -cd "$(dirname "$0")" -source "./lib.sh" - -#run sp-proj-dhis-test-00.clt1.theark.cloud sudo usermod -a -G docker asanchez - -run boone-ip-pro bash push-pro-docker.sh -#run boone-ip-test bash start-training-from-pro.sh diff --git a/deploy/clone-pro.sh b/deploy/clone-pro.sh new file mode 100644 index 00000000..2b3f815b --- /dev/null +++ b/deploy/clone-pro.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -e -u -o pipefail + +# Requirements: vendorlink SSH channel open with spintldhis01, stintldhis01, sdintldhis01. + +cd "$(dirname "$0")" +source "./lib.sh" + +clone() { + setup_training + image_pro=$(run spintldhis01 bash -x push-pro-docker.sh --skip_dump) + run stintldhis01 bash deploy-test-from-pro.sh "$image_pro" + run stintldhis01 bash -x deploy-training-from-pro.sh "$image_pro" + run sdintldhis01 bash -x deploy-dev-from-pro.sh "$image_pro" +} + +setup_training() { + local repo_url="https://github.com/eyeseetea/project-monitoring-app" + + if test -e "project-monitoring-app"; then + cd "project-monitoring-app" + git fetch + cd .. + else + git clone "$repo_url" + fi + +} + +clone diff --git a/deploy/deploy-dev-from-pro.sh b/deploy/deploy-dev-from-pro.sh new file mode 100755 index 00000000..ec398abe --- /dev/null +++ b/deploy/deploy-dev-from-pro.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# shellcheck disable=SC2001 + +set -e -u -o pipefail + +cd "$(dirname "$0")" +script_dir=$(pwd) +source "./lib.sh" +source "./tasks.sh" + +start_from_pro() { + local url=$1 image_pro=$2 + image=$(echo "$image_pro" | sed "s/ip-pro/ip-dev/") + + export TMPDIR=/data/tmp + + local image_running + image_running=$(d2-docker list | grep RUN | awk '{print $1}' | grep -m1 ip-dev) || true + + if test "$image_running"; then + d2-docker commit "$image_running" + docker tag "$image_running" "$image_running-$(timestamp)" + d2-docker stop "$image_running" + fi + + d2-docker pull "$image_pro" + docker tag "$image_pro" "$image" + sudo image="$image" /usr/local/bin/start-dhis2-dev + + wait_for_dhis2_server "$url" +} + +post_clone() { + local url=$1 + + change_server_name "$url" "SP Platform - DEV" + set_logos "$url" "$script_dir/icons/dev" + set_email_password "$url" +} + +main() { + local image_pro=$1 + url=$(get_url 80) + + start_from_pro "$url" "$image_pro" + post_clone "$url" +} + +main "$@" diff --git a/deploy/deploy-test-from-pro.sh b/deploy/deploy-test-from-pro.sh index 1de1e26d..633c62bb 100755 --- a/deploy/deploy-test-from-pro.sh +++ b/deploy/deploy-test-from-pro.sh @@ -6,21 +6,22 @@ script_dir=$(pwd) source "./lib.sh" source "./tasks.sh" -image_test="docker.eyeseetea.com/samaritans/dhis2-data:40.4.0-sp-ip-test" - start_from_pro() { - local url=$1 - local image_test_running + local url=$1 image_pro=$2 + # shellcheck disable=SC2001 + image_test=$(echo "$image_pro" | sed "s/ip-pro/ip-test/") + + local running_image + running_image=$(d2-docker list | grep RUN | awk '{print $1}' | grep -m1 ip-test) || true - image_test_running=$(d2-docker list | grep RUN | awk '{print $1}' | grep -m1 ip-test) || true - if test "$image_test_running"; then - d2-docker commit "$image_test_running" - d2-docker copy "$image_test_running" "backup/sp-ip-test-$(timestamp)" - d2-docker stop "$image_test_running" + if test "$running_image"; then + d2-docker commit "$running_image" + docker tag "$running_image" "$running_image-$(timestamp)" + d2-docker stop "$running_image" fi d2-docker pull "$image_pro" - d2-docker copy "$image_pro" "$image_test" + docker tag "$image_pro" "$image_test" sudo image=$image_test /usr/local/bin/start-dhis2-test wait_for_dhis2_server "$url" @@ -29,9 +30,10 @@ start_from_pro() { post_clone() { local url=$1 - #set_email_password "$url" change_server_name "$url" "SP Platform - Test" set_logos "$url" "$script_dir/test-icons" + set_email_password "$url" + run_analytics "$url" } main() { diff --git a/deploy/deploy-training-from-pro.sh b/deploy/deploy-training-from-pro.sh new file mode 100644 index 00000000..5506b8d5 --- /dev/null +++ b/deploy/deploy-training-from-pro.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -e -u -o pipefail + +cd "$(dirname "$0")" +script_dir=$(pwd) +source "./lib.sh" +source "./tasks.sh" + +load_training_projects() { + local url=$1 + cd "$script_dir/project-monitoring-app" || return 1 + yarn ts-node src/scripts/projects.ts --url="$url" import training-projects.json +} + +training_post_clone() { + local url=$1 + set_email_password "$url" + enable_users "$url" "traindatareviewer,traindataviewer,traindataentry" + change_server_name "$url" "SP Platform - Training" + add_users_to_maintainer_roles "$url" + set_logos "$url" "$script_dir/icons/training" + run_analytics "$url" +} + +get_app_version() { + local url=$1 + curl -sS -u "$auth" "$url/api/apps" | + jq '.[] | select(.key == "Data-Management-App").version' -r +} + +get_project_monitoring_app_source() { + local url=$1 + + app_version=$(get_app_version "$url") + cd "$script_dir" || return 1 + + # Connection to github is flaky from SP servers, clone locally and rsync it (before the script is run) + cd "project-monitoring-app" || return 1 + + git checkout v"$app_version" -f + yarn install + yarn add ts-node@10.8.1 + yarn localize +} + +save_last_training_projects() { + local url=$1 + date=$(date --date="60 day ago" "+%Y-%m-%d") + cd "$script_dir/project-monitoring-app" || return 1 + yarn ts-node src/scripts/projects.ts --url="$url" --from="$date" export training-projects.json +} + +delete_projects() { + local image_training=$1 + d2-docker run-sql -i "$image_training" "$script_dir/sql/create-guest-user.sql" + d2-docker run-sql -i "$image_training" "$script_dir/sql/empty_data_tables_228.sql" + d2-docker run-sql -i "$image_training" "$script_dir/sql/delete-projects.sql" +} + +start_from_pro() { + local url=$1 image_pro=$2 + # shellcheck disable=SC2001 + image_training=$(echo "$image_pro" | sed "s/ip-pro/ip-training/") + + { + d2-docker pull "$image_pro" + if test 1 = 2; then + current_image=$(d2-docker list | grep RUN | awk '{print $1}' | grep -m1 ip-training) || true + + if test "$current_image"; then + d2-docker commit "$current_image" + docker tag "$current_image" "$current_image-$(timestamp)" + d2-docker stop "$current_image" + fi + + docker tag "$image_pro" "$image_training" + sudo image="$image_training" /usr/local/bin/start-dhis2-training + wait_for_dhis2_server "$url" + fi + } >&2 + + echo "$image_training" +} + +main() { + local image_pro=$1 + url=$(get_url 81) + + if curl -s "$url"; then + get_project_monitoring_app_source "$url" + save_last_training_projects "$url" + fi + + image_training=$(start_from_pro "$url" "$image_pro") + delete_projects "$image_training" + load_training_projects "$url" + training_post_clone "$url" +} + +main "$@" diff --git a/deploy/icons/dev/logo_banner.png b/deploy/icons/dev/logo_banner.png new file mode 100644 index 00000000..5f623c02 Binary files /dev/null and b/deploy/icons/dev/logo_banner.png differ diff --git a/deploy/icons/dev/logo_front.png b/deploy/icons/dev/logo_front.png new file mode 100644 index 00000000..79602be1 Binary files /dev/null and b/deploy/icons/dev/logo_front.png differ diff --git a/deploy/icons/training/logo_banner.png b/deploy/icons/training/logo_banner.png new file mode 100644 index 00000000..90ffe04e Binary files /dev/null and b/deploy/icons/training/logo_banner.png differ diff --git a/deploy/icons/training/logo_front.png b/deploy/icons/training/logo_front.png new file mode 100644 index 00000000..5456b3dd Binary files /dev/null and b/deploy/icons/training/logo_front.png differ diff --git a/deploy/lib.sh b/deploy/lib.sh index 298e1adc..bb0c2817 100755 --- a/deploy/lib.sh +++ b/deploy/lib.sh @@ -2,10 +2,6 @@ script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]:-$0}")")" source "$script_dir/auth.sh" -export image_pro="docker.eyeseetea.com/eyeseetea/dhis2-data:2.36.11.1-sp-ip-pro" -export image_dev="docker.eyeseetea.com/samaritans/dhis2-data:2.36.11.1-sp-ip-dev" -export image_training="docker.eyeseetea.com/samaritans/dhis2-data:2.36.11.1-sp-ip-training" - debug() { echo "$@" >&2 } @@ -20,7 +16,7 @@ run() { local command=$2 shift 2 - debug "Copy deploy folder" + debug "Copy deploy folder to $host" rsync -a . "$host":deploy/ debug "Run: $command $*" ssh "$host" "cd deploy &&" "$command" "$@" @@ -30,7 +26,7 @@ wait_for_dhis2_server() { local url=$url local port port=$(echo "$url" | grep -o ':[[:digit:]]*$' | cut -d: -f2) - echo "Wait for server: port=$port" + debug "Wait for server: port=$port" while ! curl -sS -f "http://localhost:$port"; do sleep 10 diff --git a/deploy/opts.sh b/deploy/opts.sh new file mode 100644 index 00000000..49d5bab9 --- /dev/null +++ b/deploy/opts.sh @@ -0,0 +1,209 @@ +#!/bin/bash + +declare -a __OPTS__=() __VARS__=() + +function opts() { + function opt_name() { + local opt=$1 + local type name + type=$(opt_type "$opt") + name=$(echo "${opt/--no-/}" | tr -d '\-=[]') + + if [[ $type == array && ${name: -1} == s ]]; then + name=${name%?} + fi + echo "$name" + } + + function var_name() { + local opt=$1 + echo "${opt/--no-/}" | tr -d '\-=[]' + } + + function short_name() { + local opt=$1 + local name=${opt/[]//} + if [[ $name =~ (\[(.)\]) ]]; then + echo "${BASH_REMATCH[2]}" + fi + } + + function opt_type() { + local opt=$1 + local type=flag + [[ ! $opt =~ =$ ]] || type=var + [[ ! $opt =~ \[\] ]] || type=array + echo $type + } + + function negated() { + local opt=$1 + [[ $opt =~ ^--no- ]] && echo true || echo false + } + + local opts=("$@") + + for opt in "${opts[@]}"; do + __OPTS__[${#__OPTS__[@]}]=" + opt=$(opt_name "$opt") + name=$(var_name "$opt") + type=$(opt_type "$opt") + short=$(short_name "$opt") + negated=$(negated "$opt") + " + done +} + +function opts_eval() { + function puts() { + echo "$@" >&2 + } + + function opts_declare() { + for opt in "${__OPTS__[@]}"; do + local type="" name="" short="" negated="" + eval "$opt" + [[ $type != var ]] || store_var "$name=" + [[ $type != array ]] || store_var "$name=()" + [[ $type != flag ]] || store_var "$name=$([[ $negated == true ]] && echo true || echo false)" + done + } + + function store_var() { + __VARS__[${#__VARS__[@]}]="$1" + } + + function opt_value() { + local arg=$1 opt=$2 name=$3 short=$4 + [[ $arg =~ --$opt=(.*)$ || $arg =~ -$short=(.*)$ ]] || return 1 + echo "${BASH_REMATCH[1]}" + } + + function set_var() { + local name=$3 value= + value=$(opt_value "$@") && store_var "$name=\"$value\"" + } + + function set_array() { + local name=$3 value= + value=$(opt_value "$@") && store_var "$name""[\${#""$name""[@]}]=\"$value\"" + } + + function set_flag() { + local arg=$1 opt=$2 name=$3 short=$4 value= + + if [[ $arg == -$short ]]; then + value=true + elif [[ $arg =~ --(no-)?$opt$ ]]; then + value=$([[ -n ${BASH_REMATCH[1]} ]] && echo false || echo true) + fi + + [[ -n $value ]] && store_var "$name=$value" + } + + function opts_parse() { + local arg=$1 + + for opt in "${__OPTS__[@]}"; do + local type="" name="" short="" negated="" value="" + eval "$opt" + if "set_$type" "$arg" "$opt" "$name" "$short"; then + return 0 + fi + done + + return 1 + } + + function opts_join_assignment() { + + local arg=$1 type="" name="" short="" negated="" value="" + for opt in "${__OPTS__[@]}"; do + eval "$opt" + if [[ $type != flag && ($arg == --$opt || $arg == -$short) ]]; then # && (( $# > 0 )) + return 0 + fi + done + + return 1 + } + + opts_declare + + local arg var + args=(0) + + while (($# > 0)); do + if opts_join_assignment "$1"; then + arg="$1=$2" + shift || true + else + arg=$1 + fi + shift || true + + if [[ $arg == '--' ]]; then + args=("${args[@]}" "$@") + break + elif opts_parse "$arg"; then + true + elif [[ $arg =~ ^- ]]; then + echo "Unknown option: ${arg}" >&2 && exit 1 + else + args[${#args[@]}]="$arg" + fi + done + + for var in "${__VARS__[@]}"; do + eval "$var" + done + + args=("${args[@]:1}") +} + +function opt() { + function opt_type() { + local opt line + line=$(echo "${__OPTS__[@]}" | grep -A 1 "name=$1" | tail -n 1) + echo "${line#*=}" + } + + function opt_var() { + echo "--$1=\"$(eval "echo \$$1")\"" + } + + function opt_array() { + local _length + _length=$(eval "echo \${#$1[@]}") + ((_length == 0)) || echo "$( + for ((_i = 0; _i < _length; _i++)); do + echo "--${1%s}=\"$(eval "echo \${$1[$_i]}")\"" + done + )" | tr "\n" ' ' | sed 's/ *$//' + } + + function opt_flag() { + [[ $(eval "echo \$$1") == false ]] || echo "--$1" + } + + "opt_$(opt_type "$1")" "$1" +} + +options() { + local index=0 + + for arg in "$@"; do + if [[ $arg == "--" ]]; then + opts "${@:1:$index}" + shift $((index + 1)) + opts_eval "$@" + return + else + index=$((index + 1)) + fi + done + + return 1 +} + +export -f options diff --git a/deploy/push-pro-docker.sh b/deploy/push-pro-docker.sh index 03155946..632f1028 100755 --- a/deploy/push-pro-docker.sh +++ b/deploy/push-pro-docker.sh @@ -1,13 +1,32 @@ #!/bin/bash set -e -u -o pipefail -name="docker.eyeseetea.com/eyeseetea/dhis2-data:2.36.11.1-sp-ip-pro" -sql_filename="$(basename $name).sql.gz" +cd "$(dirname "$0")" +source "./lib.sh" +source "./auth.sh" +source "./opts.sh" -echo "Dump DB: $name -> $sql_filename" -sudo -u postgres pg_dump dhis2 | gzip >"$sql_filename" +dump_and_push() { + options --skip_dump -- "$@" -echo "Create d2-docker image: $name" -sudo d2-docker create data --sql="$sql_filename" "$name" \ - --apps-dir=/home/dhis/config/files/apps/ --documents-dir=/home/dhis/config/files/document/ -sudo docker push "$name" + version=$(curl -sS -u "$auth" http://localhost/api/system/info.json | jq -r .version | sed "s/2\.4/4/") + name="docker.eyeseetea.com/samaritans/dhis2-data:$version-sp-ip-pro" + debug "Docker image: $name" + + if test -z "${skip_dump:-}"; then + sql_filename="/tmp/db.sql.gz" + cd /tmp + debug "Dump DB: $name -> $sql_filename" + sudo -u postgres pg_dump dhis2 | gzip >"$sql_filename" + + debug "Create d2-docker image: $name" + sudo d2-docker create data --sql="$sql_filename" "$name" \ + --apps-dir=/home/dhis/ip-40/config/files/apps/ \ + --documents-dir=/home/dhis/ip-40/config/files/document/ + sudo docker push "$name" + fi + + echo "$name" +} + +dump_and_push "$@" diff --git a/deploy/sql/create-guest-user.sql b/deploy/sql/create-guest-user.sql new file mode 100644 index 00000000..39368baa --- /dev/null +++ b/deploy/sql/create-guest-user.sql @@ -0,0 +1,8 @@ +\c postgres; +CREATE ROLE guest WITH LOGIN PASSWORD 'j9d&2r5P' NOSUPERUSER INHERIT NOCREATEDB NOCREATEROLE NOREPLICATION VALID UNTIL 'infinity'; +GRANT CONNECT ON DATABASE dhis2 TO guest; +\c dhis2; +GRANT USAGE ON SCHEMA public TO guest; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO guest; +GRANT SELECT ON ALL SEQUENCES IN SCHEMA public TO guest; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO guest; diff --git a/deploy/sql/delete-projects.sql b/deploy/sql/delete-projects.sql new file mode 100644 index 00000000..300af09e --- /dev/null +++ b/deploy/sql/delete-projects.sql @@ -0,0 +1,80 @@ +DELETE FROM + datasetindicators; + +DELETE FROM + datasetoperands; + +DELETE FROM + visualization_organisationunits; + +DELETE FROM + sectiondataelements; + +DELETE FROM + section; + +DELETE FROM + datasetsource; + +DELETE FROM + completedatasetregistration; + +DELETE FROM + datasetelement; + +DELETE FROM + datainputperiod; + +DELETE FROM + dataset; + +DELETE FROM + orgunitgroupmembers +WHERE + (organisationunitid) IN ( + SELECT + ou.organisationunitid + FROM + organisationunit ou + JOIN orgunitgroupmembers oug USING (organisationunitid) + WHERE + ou.hierarchylevel >= 3 + ); + +DELETE FROM + userdatavieworgunits +WHERE + (organisationunitid) IN ( + SELECT + ou.organisationunitid + FROM + organisationunit ou + JOIN userdatavieworgunits oug USING (organisationunitid) + WHERE + ou.hierarchylevel >= 3 + ); + +DELETE FROM + usermembership +WHERE + (organisationunitid) IN ( + SELECT + ou.organisationunitid + FROM + organisationunit ou + JOIN usermembership oug USING (organisationunitid) + WHERE + ou.hierarchylevel >= 3 + ); + +DELETE FROM + userteisearchorgunits; + +-- This fails +-- dhis2-# WHERE hierarchylevel = 3; +-- ERROR: update or delete on table "organisationunit" violates foreign key constraint "fk_visualization_organisationunits_organisationunitid" on table "visualization_organisationunits" +-- DETAIL: Key (organisationunitid)=(2606892) is still referenced from table "visualization_organisationunits" +DELETE FROM + organisationunit +WHERE + hierarchylevel >= 3; diff --git a/deploy/sql/empty_data_tables_228.sql b/deploy/sql/empty_data_tables_228.sql new file mode 100644 index 00000000..1409de72 --- /dev/null +++ b/deploy/sql/empty_data_tables_228.sql @@ -0,0 +1,59 @@ +DELETE FROM + trackedentitydatavalueaudit; + +DELETE FROM + datavalueaudit; + +DELETE FROM + datavalue; + +DELETE FROM + programstageinstance_messageconversation; + +DELETE FROM + programstageinstancecomments; + +DELETE FROM + programstageinstance; + +DELETE FROM + trackedentityattributevalue; + +DELETE FROM + programinstancecomments; + +DELETE FROM + programinstance; + +DELETE FROM + programownershiphistory; + +DELETE FROM + trackedentityattributevalueaudit; + +DELETE FROM + trackedentityprogramowner; + +DELETE FROM + trackedentityinstance; + +DELETE FROM + dataapprovalaudit; + +DELETE FROM + dataapproval; + +DELETE FROM + interpretation_comments; + +DELETE FROM + interpretationcomment; + +DELETE FROM + messageconversation_messages; + +DELETE FROM + messageconversation_usermessages; + +DELETE FROM + messageconversation; diff --git a/deploy/tasks.sh b/deploy/tasks.sh index e89852c8..c1813804 100755 --- a/deploy/tasks.sh +++ b/deploy/tasks.sh @@ -1,6 +1,5 @@ #!/bin/bash script_dir="$(dirname "$(readlink -f "${BASH_SOURCE[0]:-$0}")")" -# shellcheck source=./lib.sh source "$script_dir/lib.sh" add_users_to_maintainer_roles() { @@ -43,16 +42,16 @@ enable_users() { set_email_password() { local url=$1 - echo "Set email password" + debug "Set SMTP password" curl -sS -H 'Content-Type: text/plain' -u "$auth" \ "$url/api/systemSettings/keyEmailPassword" \ - -d 'RLIi96f3TJSBsV2h1IO6Vy52ToWzH0' | jq -r '.status' + -d 'NO_PASSWORD_NECESSARY' | jq -r '.status' } set_logos() { local url=$1 folder=$2 - echo "Set logs: url=$url, folder=$folder" + debug "Set logs: url=$url, folder=$folder" curl -sS -F "file=@$folder/logo_front.png;type=image/png" \ -X POST -u "$auth" \ @@ -64,3 +63,8 @@ set_logos() { -H "Content-Type: multipart/form-data" \ "$url/api/staticContent/logo_banner" } + +run_analytics() { + local url=$1 + curl -sS -u "$auth" "$url/api/resourceTables/analytics" -X POST | jq +} diff --git a/i18n/en.pot b/i18n/en.pot index a0143cd8..0209b1fc 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: 2023-12-05T11:01:02.938Z\n" -"PO-Revision-Date: 2023-12-05T11:01:02.938Z\n" +"POT-Creation-Date: 2024-12-11T14:02:52.077Z\n" +"PO-Revision-Date: 2024-12-11T14:02:52.077Z\n" msgid "Validating Project" msgstr "" @@ -320,14 +320,20 @@ msgstr "" msgid "Checking Existing Data" msgstr "" -msgid "Project created" -msgstr "" - msgid "" "Note: only users with permissions in the selected country ({{country}}) can " "be added to this project" msgstr "" +msgid "Edit" +msgstr "" + +msgid "Start Month" +msgstr "" + +msgid "End Month" +msgstr "" + msgid "Cancel Project creation?" msgstr "" @@ -336,6 +342,39 @@ msgid "" "lost. Are you sure you want to proceed?" msgstr "" +msgid "Cannot be blank: {{fieldName}}" +msgstr "" + +msgid "{{value}} is not a valid value for {{fieldName}}" +msgstr "" + +msgid "{{fieldName}} must be a positive number" +msgstr "" + +msgid "Start date must be before end date" +msgstr "" + +msgid "No unique indicators selected" +msgstr "" + +msgid "Cannot delete a protected period" +msgstr "" + +msgid "Cannot save indicators without comments for period: {{period}}" +msgstr "" + +msgid "Period is equal to the predefined annual/semi-annual period" +msgstr "" + +msgid "Cannot save a protected period" +msgstr "" + +msgid "Already exist a period with the same months" +msgstr "" + +msgid "Cannot save duplicate periods" +msgstr "" + msgid "Error saving dashboard" msgstr "" @@ -523,6 +562,9 @@ msgstr "" msgid "Partner" msgstr "" +msgid "Unique Indicators" +msgstr "" + msgid "Award Number should be a number of 5 digits" msgstr "" @@ -687,6 +729,9 @@ msgstr "" msgid "MER" msgstr "" +msgid "Unique Beneficiary" +msgstr "" + msgid "[SP Platform] Request for Data Review: {{-name}} ({{code}})" msgstr "" @@ -720,6 +765,9 @@ msgid "" "{{message}}" msgstr "" +msgid "Project created" +msgstr "" + msgid "Project '{{projectName}}' was {{action}} by {{user}} ({{username}})" msgstr "" @@ -834,6 +882,48 @@ msgstr "" msgid "Non-COVID-19" msgstr "" +msgid "Country Project & Indicators" +msgstr "" + +msgid "Select Year" +msgstr "" + +msgid "Select period" +msgstr "" + +msgid "No projects found for selected period: {{period}}" +msgstr "" + +msgid "Any changes will be lost. Are you sure you want to proceed?" +msgstr "" + +msgid "Saving..." +msgstr "" + +msgid "Loading projects and indicators..." +msgstr "" + +msgid "Selected Activity Indicators" +msgstr "" + +msgid "Unique Beneficiaries" +msgstr "" + +msgid "Include?" +msgstr "" + +msgid "Total Unique Served" +msgstr "" + +msgid "Country Unique Beneficiaries" +msgstr "" + +msgid "N/A" +msgstr "" + +msgid "Country Projects & Indicators" +msgstr "" + msgid "Award Number Dashboard" msgstr "" @@ -917,6 +1007,56 @@ msgid "" "Data Entry Section" msgstr "" +msgid "" +"Please review the following indicators which have been updated since " +"{{monthName}}" +msgstr "" + +msgid "Project Indicators Validation" +msgstr "" + +msgid "Select Period" +msgstr "" + +msgid "Unique in Project" +msgstr "" + +msgid "Previous Value" +msgstr "" + +msgid "Next Value" +msgstr "" + +msgid "Blank" +msgstr "" + +msgid "Editable New" +msgstr "" + +msgid "Returning from previous Project" +msgstr "" + +msgid "Comment is required" +msgstr "" + +msgid "Saving Indicators..." +msgstr "" + +msgid "Indicators saved" +msgstr "" + +msgid "Loading Project" +msgstr "" + +msgid "Loading Indicators..." +msgstr "" + +msgid "Period not found" +msgstr "" + +msgid "Value must be greater than or equal to zero" +msgstr "" + msgid "Cannot load project" msgstr "" @@ -926,10 +1066,10 @@ msgstr "" msgid "Country & Project Locations" msgstr "" -msgid "Selection of Indicators" +msgid "Indicators" msgstr "" -msgid "Selection of MER Indicators" +msgid "MER Indicators" msgstr "" msgid "Username Access" @@ -952,9 +1092,20 @@ msgstr "" msgid "Edit project" msgstr "" +msgid "Clone project" +msgstr "" + msgid "New project" msgstr "" +msgid "" +"Please review all prefilled information including Project numbers and dates " +"which MUST be updated." +msgstr "" + +msgid "OK" +msgstr "" + msgid "" "- Starred (*) items are required to be filled out\n" " - Award Number- refers to the first 5 digits of the project’s award " @@ -987,6 +1138,11 @@ msgid "" " - Up to three sectors and three indicators per sectors." msgstr "" +msgid "" +"Select indicators in each project that represent unique beneficiaries in " +"the project" +msgstr "" + msgid "" "For activity indicators that include a response to COVID-19 (examples: " "COVID-19 messaging, COVID-19 specific healthcare worker trainings, etc.), " @@ -1021,6 +1177,9 @@ msgstr "" msgid "API Link" msgstr "" +msgid "Manage Unique Beneficiaries Periods" +msgstr "" + msgid "Add Actual Values" msgstr "" @@ -1036,7 +1195,7 @@ msgstr "" msgid "Download Data" msgstr "" -msgid "Edit" +msgid "Clone" msgstr "" msgid "User groups" @@ -1072,9 +1231,6 @@ msgstr "" msgid "The report must be saved before it can be downloaded" msgstr "" -msgid "Any changes will be lost. Are you sure you want to proceed?" -msgstr "" - msgid "Date" msgstr "" @@ -1128,6 +1284,66 @@ msgstr "" msgid "You have reached the limit for this field" msgstr "" +msgid "Saving Period..." +msgstr "" + +msgid "Period saved successfully" +msgstr "" + +msgid "Removing Period..." +msgstr "" + +msgid "Period removed successfully" +msgstr "" + +msgid "Unique Beneficiaries Periods" +msgstr "" + +msgid "{{actionPeriod}} Period" +msgstr "" + +msgid "Create" +msgstr "" + +msgid "Are you sure you want to delete the period: {{period}}?" +msgstr "" + +msgid "January" +msgstr "" + +msgid "February" +msgstr "" + +msgid "March" +msgstr "" + +msgid "April" +msgstr "" + +msgid "May" +msgstr "" + +msgid "June" +msgstr "" + +msgid "July" +msgstr "" + +msgid "August" +msgstr "" + +msgid "September" +msgstr "" + +msgid "October" +msgstr "" + +msgid "November" +msgstr "" + +msgid "December" +msgstr "" + msgid "{{field}} cannot be blank" msgstr "" diff --git a/jest.config.js b/jest.config.js index 344b01b8..273511fa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { collectCoverageFrom: ["src/**/*.js"], testPathIgnorePatterns: ["/node_modules/", "/cypress"], - transformIgnorePatterns: ["/node_modules/(?!@dhis2)"], + transformIgnorePatterns: ["/node_modules/(?!@eyeseetea/d2-ui-components)"], modulePaths: ["src"], moduleDirectories: ["node_modules"], moduleNameMapper: { diff --git a/package.json b/package.json index 8d70f811..126e80b9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "data-management-app", "description": "DHIS2 Data Management App", - "version": "2.0.0", + "version": "2.1.0", "license": "GPL-3.0", "author": "EyeSeeTea team", "homepage": ".", @@ -10,7 +10,7 @@ "url": "git+https://github.com/eyeseetea/project-monitoring-app.git" }, "dependencies": { - "@dhis2/app-runtime": "3.2.1", + "@dhis2/app-runtime": "3.10.4", "@dhis2/d2-i18n": "1.1.1", "@dhis2/d2-i18n-extract": "1.0.8", "@dhis2/d2-i18n-generate": "1.2.0", @@ -18,9 +18,10 @@ "@dhis2/d2-ui-forms": "^6.5.3", "@dhis2/ui": "6.12.0", "@dhis2/ui-core": "^4.8.0", - "@eyeseetea/d2-api": "1.14.0", + "@eyeseetea/d2-api": "1.16.0-beta.13", "@eyeseetea/d2-ui-components": "2.7.0", "@eyeseetea/feedback-component": "0.1.2", + "@krakenjs/post-robot": "^11.0.0", "@material-ui/core": "4.12.3", "@material-ui/icons": "4.11.3", "@material-ui/styles": "4.11.4", @@ -52,10 +53,10 @@ "styled-components": "^5.2.1", "styled-jsx": "3.4.1", "use-debounce": "7.0.0", - "word-wrap": "1.2.5", - "xlsx": "^0.18.5" + "word-wrap": "1.2.5" }, "scripts": { + "analyze": "source-map-explorer 'build/static/js/*.js'", "prestart": "yarn localize && d2-manifest package.json manifest.webapp", "start": "react-scripts start", "prebuild": "yarn localize && yarn test", @@ -133,9 +134,11 @@ "jest": "26.6.3", "parse-typed-args": "^0.2.0", "prettier": "2.5.1", + "source-map-explorer": "^2.5.3", "ts-node": "10.9.2", "typescript": "4.9.3", - "wait-on": "5.2.1" + "wait-on": "5.2.1", + "xlsx": "^0.18.5" }, "manifest.webapp": { "name": "Data Management App", diff --git a/src/CompositionRoot.ts b/src/CompositionRoot.ts index 34d53586..39397569 100644 --- a/src/CompositionRoot.ts +++ b/src/CompositionRoot.ts @@ -1,33 +1,59 @@ import { DataElementD2Repository } from "./data/repositories/DataElementD2Repository"; import { DataValueD2Repository } from "./data/repositories/DataValueD2Repository"; -import { DataValueExportJsonRepository } from "./data/repositories/DataValueExportJsonRepository"; -import { ExportDataElementJsonRepository } from "./data/repositories/ExportDataElementJsonRepository"; -import { ImportDataElementSpreadSheetRepository } from "./data/repositories/ImportDataElementSpreadSheetRepository"; -import { OrgUnitD2Repository } from "./data/repositories/OrgUnitD2Repository"; -import { ImportDataElementsUseCase } from "./domain/usecases/ImportDataElementsUseCase"; +import { IndicatorReportD2Repository } from "./data/repositories/IndicatorReportD2Repository"; +import { ProjectD2Repository } from "./data/repositories/ProjectD2Repository"; +import { UniqueBeneficiariesSettingsD2Repository } from "./data/repositories/UniqueBeneficiariesSettingsD2Repository"; +import { UniquePeriodD2Repository } from "./data/repositories/UniquePeriodD2Repository"; +import { GetIndicatorsValidationUseCase } from "./domain/usecases/GetIndicatorsValidationUseCase"; +import { GetProjectByIdUseCase } from "./domain/usecases/GetProjectByIdUseCase"; +import { GetProjectsByCountryUseCase } from "./domain/usecases/GetProjectsByCountryUseCase"; +import { GetUniqueBeneficiariesSettingsUseCase } from "./domain/usecases/GetUniqueBeneficiariesSettingsUseCase"; +import { RemoveUniqueBeneficiariesPeriodUseCase } from "./domain/usecases/RemoveUniqueBeneficiariesPeriodUseCase"; +import { SaveIndicatorReportUseCase } from "./domain/usecases/SaveIndicatorReportUseCase"; +import { SaveIndicatorsValidationUseCase } from "./domain/usecases/SaveIndicatorsValidationUseCase"; +import { SaveUniquePeriodsUseCase } from "./domain/usecases/SaveUniquePeriodsUseCase"; import { Config } from "./models/Config"; import { D2Api } from "./types/d2-api"; export function getCompositionRoot(api: D2Api, config: Config) { - const dataValueRepository = new DataValueD2Repository(api); - const dataElementRepository = new DataElementD2Repository(api, config); - const importDataElementSpreadSheetRepository = new ImportDataElementSpreadSheetRepository( + const uniqueBeneficiariesSettingsRepository = new UniqueBeneficiariesSettingsD2Repository( api, config ); - const exportDataElementJsonRepository = new ExportDataElementJsonRepository(api, config); - const dataValueExportRepository = new DataValueExportJsonRepository(); - const orgUnitRepository = new OrgUnitD2Repository(api); + const dataValueRepository = new DataValueD2Repository(api); + const dataElementRepository = new DataElementD2Repository(api, config); + + const projectRepository = new ProjectD2Repository(api, config); + const indicatorReportRepository = new IndicatorReportD2Repository(api, config); + const periodRepository = new UniquePeriodD2Repository(api, config); return { - dataElements: { - import: new ImportDataElementsUseCase( - importDataElementSpreadSheetRepository, - dataElementRepository, - exportDataElementJsonRepository, + uniqueBeneficiaries: { + getSettings: new GetUniqueBeneficiariesSettingsUseCase( + uniqueBeneficiariesSettingsRepository + ), + saveSettings: new SaveUniquePeriodsUseCase(periodRepository), + removePeriod: new RemoveUniqueBeneficiariesPeriodUseCase(periodRepository), + }, + indicators: { + getValidation: new GetIndicatorsValidationUseCase( dataValueRepository, - dataValueExportRepository, - orgUnitRepository + uniqueBeneficiariesSettingsRepository, + projectRepository, + config + ), + saveValidation: new SaveIndicatorsValidationUseCase( + uniqueBeneficiariesSettingsRepository + ), + saveReports: new SaveIndicatorReportUseCase(indicatorReportRepository), + }, + projects: { + getById: new GetProjectByIdUseCase(projectRepository), + getByCountry: new GetProjectsByCountryUseCase( + projectRepository, + uniqueBeneficiariesSettingsRepository, + dataElementRepository, + indicatorReportRepository ), }, }; diff --git a/src/components/app/App.tsx b/src/components/app/App.tsx index 2d2433f0..fa7d7022 100644 --- a/src/components/app/App.tsx +++ b/src/components/app/App.tsx @@ -26,6 +26,7 @@ import { useMigrations } from "../migrations/hooks"; import { appConfig } from "../../app-config"; import { isTest } from "../../utils/testing"; import i18n from "../../locales"; +import { getCompositionRoot } from "../../CompositionRoot"; const settingsQuery = { userSettings: { resource: "/userSettings" } }; @@ -51,6 +52,7 @@ const App: React.FC = props => { const config = await getConfig(api); const currentUser = new User(config); setUsername(currentUser.data.username); + const compositionRoot = getCompositionRoot(api, config); const appContext: AppContext = { d2, api, @@ -60,6 +62,7 @@ const App: React.FC = props => { isTest: isTest(), appConfig, dhis2Url, + compositionRoot, }; setAppContext(appContext); diff --git a/src/components/steps/data-elements/DataElementsFilters.tsx b/src/components/steps/data-elements/DataElementsFilters.tsx index a8003ca1..f9e54164 100644 --- a/src/components/steps/data-elements/DataElementsFilters.tsx +++ b/src/components/steps/data-elements/DataElementsFilters.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { indicatorTypes, IndicatorType } from "../../../models/dataElementsSet"; +import { indicatorTypes, IndicatorType, PeopleOrBenefit } from "../../../models/dataElementsSet"; import Dropdown from "../../dropdown/Dropdown"; import i18n from "../../../locales"; import { Checkbox, FormControlLabel } from "@material-ui/core"; @@ -21,6 +21,7 @@ export interface Filter { indicatorType?: IndicatorType; onlySelected?: boolean; external?: string; + peopleOrBenefit?: PeopleOrBenefit; } export interface FilterOptions { diff --git a/src/components/steps/data-elements/DataElementsStep.tsx b/src/components/steps/data-elements/DataElementsStep.tsx index 2b927726..fbbbe1e2 100644 --- a/src/components/steps/data-elements/DataElementsStep.tsx +++ b/src/components/steps/data-elements/DataElementsStep.tsx @@ -5,14 +5,16 @@ import { Id } from "../../../types/d2-api"; import DataElementsSet, { ProjectSelection } from "../../../models/dataElementsSet"; import SectionsSidebar from "../../sections-sidebar/SectionsSidebar"; import { useSectionsSidebar } from "../../sections-sidebar/sections-sidebar-hooks"; +import { Filter } from "./DataElementsFilters"; export interface DataElementsStepProps extends StepProps { onSelect(sectorId: Id, dataElementIds: Id[]): ProjectSelection; dataElementsSet: DataElementsSet; + initialFilters?: Filter; } const DataElementsStep: React.FC = props => { - const { onChange, project, dataElementsSet, onSelect } = props; + const { onChange, project, dataElementsSet, onSelect, initialFilters } = props; const { items, sectorId, setSector, onSectorsMatchChange } = useSectionsSidebar(project); const onSelectionChange = React.useCallback( @@ -36,6 +38,7 @@ const DataElementsStep: React.FC = props => { sectorId={sectorId} onSelectionChange={onSelectionChange} columns={initialColumns} + initialFilters={initialFilters} /> ); diff --git a/src/components/steps/data-elements/DataElementsTable.tsx b/src/components/steps/data-elements/DataElementsTable.tsx index fca8d5bf..b9165388 100644 --- a/src/components/steps/data-elements/DataElementsTable.tsx +++ b/src/components/steps/data-elements/DataElementsTable.tsx @@ -37,6 +37,7 @@ export interface DataElementsTableProps { actions?: TableAction[]; visibleFilters?: FilterKey[]; onSectorsMatchChange(matches: Record): void; + initialFilters?: Filter; } const paginationOptions = { @@ -88,9 +89,10 @@ const DataElementsTable: React.FC = props => { customColumns, actions, onSectorsMatchChange, + initialFilters = {}, } = props; const snackbar = useSnackbar(); - const [filter, setFilter] = useState({}); + const [filter, setFilter] = useState(initialFilters); const resetKey = { onlySelected, ...filter, sectorId }; diff --git a/src/components/steps/save/SaveStep.tsx b/src/components/steps/save/SaveStep.tsx index 236d8531..d20a82a6 100644 --- a/src/components/steps/save/SaveStep.tsx +++ b/src/components/steps/save/SaveStep.tsx @@ -123,11 +123,12 @@ const NodeList: React.FC<{ nodes: ProjectInfoNode[] }> = props => { return (
    - {nodes.map(node => { + {nodes.map((node, index) => { switch (node.type) { case "field": return ( = props => { case "value": return ( = props => { ); case "section": return ( - + ); @@ -201,8 +208,7 @@ function useSave(project: Project, action: StepProps["action"]) { if (response && response.status === "OK") { const notificator = new ProjectNotification(api, projectSaved, currentUser, isTest); notificator.notifyOnProjectSave(action); - const baseMsg = - action === "create" ? i18n.t("Project created") : i18n.t("Project updated"); + const baseMsg = ProjectNotification.buildBaseMessage(action); const msg = `${baseMsg}: ${projectSaved.name}`; history.push(generateUrl("projects")); if (isDev) saveDataValues(api, projectSaved); diff --git a/src/components/steps/unique-beneficiaries/UniqueIndicatorsStep.tsx b/src/components/steps/unique-beneficiaries/UniqueIndicatorsStep.tsx new file mode 100644 index 00000000..993bfaa0 --- /dev/null +++ b/src/components/steps/unique-beneficiaries/UniqueIndicatorsStep.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { StepProps } from "../../../pages/project-wizard/ProjectWizard"; +import { Filter } from "../data-elements/DataElementsFilters"; +import DataElementsStep, { DataElementsStepProps } from "../data-elements/DataElementsStep"; + +const initialFilters: Filter = { peopleOrBenefit: "people" }; + +const UniqueIndicatorsStep: React.FC = props => { + const { project } = props; + const getSelection = React.useCallback( + (sectorId, dataElementIds) => { + return project.updateUniqueBeneficiariesSelection(sectorId, dataElementIds); + }, + [project] + ); + + return ( + + ); +}; + +export default React.memo(UniqueIndicatorsStep); diff --git a/src/components/unique-beneficiaries/UniqueBeneficiariesTable.tsx b/src/components/unique-beneficiaries/UniqueBeneficiariesTable.tsx new file mode 100644 index 00000000..d594d8ec --- /dev/null +++ b/src/components/unique-beneficiaries/UniqueBeneficiariesTable.tsx @@ -0,0 +1,86 @@ +import { ObjectsList, useObjectsTable, TableConfig } from "@eyeseetea/d2-ui-components"; +import DeleteIcon from "@material-ui/icons/Delete"; +import EditIcon from "@material-ui/icons/Edit"; +import React from "react"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import i18n from "../../locales"; +import { getMonthNameFromNumber } from "../../utils/date"; + +export type ActionTable = { action: "edit" | "delete" | "add"; id: string }; + +export type UniqueBeneficiariesTableProps = { + onChangeAction: (options: ActionTable) => void; + periods: UniqueBeneficiariesPeriod[]; +}; + +export const UniqueBeneficiariesTable = React.memo((props: UniqueBeneficiariesTableProps) => { + const { onChangeAction, periods } = props; + const config = React.useMemo((): TableConfig => { + return { + onActionButtonClick: () => { + onChangeAction({ action: "add", id: "" }); + }, + actions: [ + { + name: "edit", + text: i18n.t("Edit"), + icon: , + isActive: showAction, + onClick: ids => onChangeAction({ action: "edit", id: getFirstItem(ids) }), + }, + { + name: "remove", + text: i18n.t("Delete"), + icon: , + isActive: showAction, + onClick: ids => onChangeAction({ action: "delete", id: getFirstItem(ids) }), + }, + ], + columns: [ + { + name: "name", + text: i18n.t("Name"), + sortable: false, + }, + { + name: "startDateMonth", + text: i18n.t("Start Month"), + sortable: false, + getValue: value => getMonthNameFromNumber(value.startDateMonth), + }, + { + name: "endDateMonth", + text: i18n.t("End Month"), + sortable: false, + getValue: value => getMonthNameFromNumber(value.endDateMonth), + }, + ], + initialSorting: { field: "name", order: "asc" }, + paginationOptions: { pageSizeInitialValue: 100, pageSizeOptions: [100] }, + }; + }, [onChangeAction]); + + const tableConfig = useObjectsTable( + config, + React.useCallback(() => { + return Promise.resolve({ + objects: periods, + pager: { page: 1, pageCount: 1, total: periods.length, pageSize: 100 }, + }); + }, [periods]) + ); + + return ; +}); + +UniqueBeneficiariesTable.displayName = "UniqueBeneficiariesTable"; + +function getFirstItem(values: string[]): string { + const value = values[0]; + if (!value) throw new Error("Value is empty"); + return value; +} + +function showAction(rows: UniqueBeneficiariesPeriod[]): boolean { + return rows.filter(row => !row.isProtected()).length === 1; +} diff --git a/src/components/utils/use-boolean.ts b/src/components/utils/use-boolean.ts new file mode 100644 index 00000000..35b5cf1a --- /dev/null +++ b/src/components/utils/use-boolean.ts @@ -0,0 +1,27 @@ +import React from "react"; + +type Callback = () => void; + +type UseBooleanReturn = [boolean, UseBooleanActions]; + +interface UseBooleanActions { + set: (newValue: boolean) => void; + toggle: Callback; + enable: Callback; + disable: Callback; +} + +export function useBooleanState(initialValue: boolean): UseBooleanReturn { + const [value, setValue] = React.useState(initialValue); + + const actions = React.useMemo(() => { + return { + set: (newValue: boolean) => setValue(newValue), + enable: () => setValue(true), + disable: () => setValue(false), + toggle: () => setValue(value_ => !value_), + }; + }, [setValue]); + + return [value, actions]; +} diff --git a/src/contexts/api-context.ts b/src/contexts/api-context.ts index f5352304..fb34696b 100644 --- a/src/contexts/api-context.ts +++ b/src/contexts/api-context.ts @@ -3,6 +3,7 @@ import React, { useContext } from "react"; import { D2Api } from "../types/d2-api"; import User from "../models/user"; import { AppConfig } from "../components/app/AppConfig"; +import { CompositionRoot } from "../CompositionRoot"; export interface AppContext { api: D2Api; @@ -13,6 +14,7 @@ export interface AppContext { isDev: boolean; isTest: boolean; dhis2Url: string; + compositionRoot: CompositionRoot; } export type CurrentUser = AppContext["currentUser"]; diff --git a/src/data/common.ts b/src/data/common.ts index 16d5ce46..03c40a2d 100644 --- a/src/data/common.ts +++ b/src/data/common.ts @@ -20,3 +20,5 @@ export function getExistingAndNewDataElements(dataElements: DataElement[]) { return { existingDataElements, existingDataElementsKeys, newDataElements, newDataElementsKeys }; } + +export const DATA_MANAGEMENT_NAMESPACE = "data-management-app"; diff --git a/src/data/common/D2ApiProject.ts b/src/data/common/D2ApiProject.ts new file mode 100644 index 00000000..71fc5e73 --- /dev/null +++ b/src/data/common/D2ApiProject.ts @@ -0,0 +1,63 @@ +import { ProjectCountry } from "../../domain/entities/IndicatorReport"; +import { Config } from "../../models/Config"; +import Project, { getDatesFromOrgUnit } from "../../models/Project"; +import { ProjectForList } from "../../models/ProjectsList"; +import { D2Api, Id } from "../../types/d2-api"; + +export class D2ApiProject { + constructor(private api: D2Api, private config: Config) {} + + async getByIds(ids: string[]): Promise { + if (ids.length === 0) return []; + + const response = await this.api.models.organisationUnits + .get({ + fields: { + id: true, + code: true, + name: true, + displayName: true, + openingDate: true, + closedDate: true, + }, + paging: false, + filter: { id: { in: ids } }, + }) + .getData(); + + return response.objects.map((d2OrgUnit): ProjectCountry => { + const { startDate, endDate } = getDatesFromOrgUnit(d2OrgUnit); + return { + closedDate: endDate?.toISOString() || "", + id: d2OrgUnit.id, + name: d2OrgUnit.displayName, + openingDate: startDate?.toISOString() || "", + }; + }); + } + + async getAllProjectsByCountry( + countryId: Id, + initialPage: number, + initialProjects: ProjectForList[] + ) { + const response = await this.getByCountry(countryId, initialPage); + const newProjects = [...initialProjects, ...response.objects]; + if (response.pager.page >= response.pager.pageCount) { + return newProjects; + } else { + const projects = await this.getByCountry(countryId, initialPage + 1); + return projects.objects; + } + } + + private getByCountry(countryId: Id, page: number) { + return Project.getList( + this.api, + this.config, + { countryIds: [countryId] }, + { field: "created", order: "desc" }, + { page: page, pageSize: 50 } + ); + } +} diff --git a/src/data/common/D2ApiUbSettings.ts b/src/data/common/D2ApiUbSettings.ts new file mode 100644 index 00000000..56b59f1d --- /dev/null +++ b/src/data/common/D2ApiUbSettings.ts @@ -0,0 +1,231 @@ +import _ from "lodash"; +import { generateUid } from "d2/uid"; +import { + IndicatorValidation, + IndicatorValidationAttrs, +} from "../../domain/entities/IndicatorValidation"; +import { Id } from "../../domain/entities/Ref"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import { D2Api, DataStore } from "../../types/d2-api"; +import { Maybe } from "../../types/utils"; +import { + IndicatorCalculation, + IndicatorCalculationAttrs, +} from "../../domain/entities/IndicatorCalculation"; +import { DATA_MANAGEMENT_NAMESPACE } from "../common"; +import { Config } from "../../models/Config"; +import { promiseMap } from "../../migrations/utils"; +import { ProjectCountry } from "../../domain/entities/IndicatorReport"; +import { D2ApiProject } from "./D2ApiProject"; + +export class D2ApiUbSettings { + private dataStore: DataStore; + private d2ApiProject: D2ApiProject; + private namespace = DATA_MANAGEMENT_NAMESPACE; + + constructor(private api: D2Api, private config: Config) { + this.dataStore = this.api.dataStore(this.namespace); + this.d2ApiProject = new D2ApiProject(api, this.config); + } + + async getAll(options: { projectsIds: Maybe }): Promise { + return this.getAllSettings(1, [], options); + } + + async get(projectId: Id): Promise { + const projects = await this.d2ApiProject.getByIds([projectId]); + const project = _(projects).first(); + if (!project) throw new Error(`Project not found for id: ${projectId}`); + + const d2Response = await this.dataStore + .get(this.buildKeyId(project.id)) + .getData(); + + return this.buildSettings(d2Response, project); + } + + private buildSettings(d2Response: Maybe, project: ProjectCountry) { + const uniqueBeneficiaries = d2Response?.uniqueBeneficiaries; + const periods = this.mergeDefaultPeriodsWithExisting(uniqueBeneficiaries?.periods); + + const indicatorsIds = uniqueBeneficiaries?.indicatorsIds || []; + const indicatorsValidation = uniqueBeneficiaries?.indicatorsValidation || []; + + const hasIndicatorsValidation = indicatorsValidation.length > 0; + const indicatorsToInclude = hasIndicatorsValidation + ? this.buildExistingIndicatorsValidation(d2Response, periods) + : IndicatorValidation.buildIndicatorsValidationFromPeriods( + periods, + indicatorsIds, + project + ); + + return { + projectId: project.id, + periods, + indicatorsIds: indicatorsIds, + indicatorsValidation: indicatorsToInclude, + }; + } + + async save(settings: UniqueBeneficiariesSettings): Promise { + const existingSettings = await this.dataStore + .get(this.buildKeyId(settings.projectId)) + .getData(); + + const currentDate = new Date().toISOString(); + + const d2SettingsToSave: D2ProjectSettings = { + ...existingSettings, + uniqueBeneficiaries: { + ...(existingSettings?.uniqueBeneficiaries || {}), + indicatorsIds: existingSettings?.uniqueBeneficiaries?.indicatorsIds || [], + periods: UniqueBeneficiariesPeriod.buildPeriods(settings.periods, generateUid()), + indicatorsValidation: this.mapIndicatorValidations(settings, currentDate), + }, + }; + + await this.dataStore.save(this.buildKeyId(settings.projectId), d2SettingsToSave).getData(); + } + + private mapIndicatorValidations( + settings: UniqueBeneficiariesSettings, + currentDate: string + ): D2IndicatorValidation[] { + return settings.indicatorsValidation.map(indicatorValidation => { + return { + year: indicatorValidation.year, + periodId: indicatorValidation.period.id, + createdAt: indicatorValidation.createdAt || currentDate, + lastUpdatedAt: currentDate, + indicatorsCalculation: indicatorValidation.indicatorsCalculation.map( + indicatorCalculation => { + return { + comment: indicatorCalculation.comment, + editableNewValue: indicatorCalculation.editableNewValue, + id: indicatorCalculation.id, + newValue: indicatorCalculation.newValue, + returningValue: indicatorCalculation.returningValue, + total: IndicatorCalculation.getTotal(indicatorCalculation), + code: "", + name: "", + }; + } + ), + }; + }); + } + + private async getAllSettings( + page: number, + settings: UniqueBeneficiariesSettings[], + options: { projectsIds: Maybe } + ): Promise { + const response = await this.getEntriesPaginated(page); + const filterByProjects = options.projectsIds + ? response.entries.filter(entry => { + const projectId = entry.key.replace(this.getProjectKey(), ""); + return options.projectsIds?.includes(projectId); + }) + : response.entries; + + const settingsFromEntries = await this.convertEntriesToSettings(filterByProjects); + const acumSettings = [...settings, ...settingsFromEntries]; + return response.entries.length === 0 + ? acumSettings + : this.getAllSettings(page + 1, acumSettings, options); + } + + private async convertEntriesToSettings( + entries: D2Entries[] + ): Promise { + const projectsIds = entries.map(entry => entry.key.replace(this.getProjectKey(), "")); + const allProjects = await promiseMap(projectsIds, projectId => + this.d2ApiProject.getByIds([projectId]) + ); + + const projects = _(allProjects).flatten().value(); + + return entries.map((d2Entry): UniqueBeneficiariesSettings => { + const projectId = d2Entry.key.replace(this.getProjectKey(), ""); + const project = projects.find(project => project.id === projectId); + if (!project) throw new Error(`Project not found for id: ${projectId}`); + return this.buildSettings(d2Entry, project); + }); + } + + private getEntriesPaginated(page: number): Promise { + return this.api + .request({ + method: "get", + url: `/dataStore/${this.namespace}`, + params: { page: page, pageSize: 50, fields: "uniqueBeneficiaries" }, + }) + .getData(); + } + + private buildExistingIndicatorsValidation( + settings: Maybe, + periods: UniqueBeneficiariesPeriod[] + ): IndicatorValidation[] { + return _(settings?.uniqueBeneficiaries?.indicatorsValidation || []) + .map(d2IndicatorValidation => { + const period = periods.find(period => period.id === d2IndicatorValidation.periodId); + if (!period) return undefined; + + return IndicatorValidation.build({ + createdAt: d2IndicatorValidation.createdAt, + lastUpdatedAt: d2IndicatorValidation.lastUpdatedAt, + period: period, + year: d2IndicatorValidation.year, + indicatorsCalculation: d2IndicatorValidation.indicatorsCalculation.map( + d2IndicatorCalculation => { + return IndicatorCalculation.build({ + ...d2IndicatorCalculation, + code: "", + }).get(); + } + ), + }).get(); + }) + .compact() + .value(); + } + + getProjectKey() { + return "project-"; + } + + buildKeyId(projectId: Id) { + return `${this.getProjectKey()}${projectId}`; + } + + private mergeDefaultPeriodsWithExisting(existingPeriods: Maybe) { + const defaultData = UniqueBeneficiariesPeriod.defaultPeriods(); + if (!existingPeriods) return defaultData; + return [ + ...existingPeriods.map(period => UniqueBeneficiariesPeriod.build(period).get()), + ...defaultData, + ]; + } +} + +type D2DataStoreResponse = { pager: { page: number; pageSize: number }; entries: D2Entries[] }; +type D2Entries = { key: string; uniqueBeneficiaries: D2UniqueBeneficiary }; + +type D2ProjectSettings = { uniqueBeneficiaries?: D2UniqueBeneficiary }; + +type D2IndicatorValidation = Omit & { + periodId: Id; + indicatorsCalculation: Omit< + IndicatorCalculationAttrs, + "previousValue" | "nextValue" | "code" + >[]; +}; + +type D2UniqueBeneficiary = { + periods: UniqueBeneficiariesPeriod[]; + indicatorsIds: Id[]; + indicatorsValidation: D2IndicatorValidation[]; +}; diff --git a/src/data/repositories/D2DataElementGroup.ts b/src/data/repositories/D2DataElementGroup.ts index 7c879260..8f79efa7 100644 --- a/src/data/repositories/D2DataElementGroup.ts +++ b/src/data/repositories/D2DataElementGroup.ts @@ -72,6 +72,7 @@ export class D2DataElementGroup { id: dataElementGroup.id, name: dataElementGroup.name, code: dataElementGroup.code, + shortName: dataElementGroup.shortName, dataElements: idsToDelete.length > 0 ? mergeDataElements.filter( @@ -146,9 +147,7 @@ export class D2DataElementGroup { return { id: dataElementGroup.id }; }), }, - { - importStrategy: "DELETE", - } + { importStrategy: "DELETE" } ) .getData(); } diff --git a/src/data/repositories/DataValueD2Repository.ts b/src/data/repositories/DataValueD2Repository.ts index d6242998..e0b69c39 100644 --- a/src/data/repositories/DataValueD2Repository.ts +++ b/src/data/repositories/DataValueD2Repository.ts @@ -5,14 +5,7 @@ import { GetDataValueOptions, } from "../../domain/repositories/DataValueRepository"; import { D2DataElementGroup } from "./D2DataElementGroup"; -import { Ref } from "../../domain/entities/Ref"; -import { DataElementGroup } from "../DataElementGroup"; -import { getUid } from "../../utils/dhis2"; -import { Maybe } from "../../types/utils"; import { writeToDisk } from "../../scripts/utils/logger"; - -const DE_DELETE_GROUP_CODE = "DEG_TEMP_REMOVE_DATAELEMENTS"; - export class DataValueD2Repository implements DataValueRepository { d2DataElementGroup: D2DataElementGroup; constructor(private api: D2Api) { @@ -20,20 +13,18 @@ export class DataValueD2Repository implements DataValueRepository { } async get(options: GetDataValueOptions): Promise { - const dataElementGroup = await this.createTempDataElementGroup(options); const res$ = this.api.dataValues.getSet({ - dataSet: [], + dataSet: options.dataSetIds || [], + dataElement: options.dataElementsIds || [], orgUnit: options.orgUnitIds, - dataElementGroup: dataElementGroup ? [dataElementGroup.id] : undefined, children: options.children, includeDeleted: false, startDate: options.startDate, endDate: options.endDate, }); const res = await res$.getData(); - if (dataElementGroup) { - await this.d2DataElementGroup.remove([dataElementGroup]); - await this.exportSqlAuditDataElements(dataElementGroup.dataElements); + if (options.logDataElements && options.dataElementsIds) { + await this.exportSqlAuditDataElements(options.dataElementsIds); } return res.dataValues; } @@ -52,24 +43,7 @@ export class DataValueD2Repository implements DataValueRepository { console.info("Soft deleted data values finished."); } - private async createTempDataElementGroup( - options: GetDataValueOptions - ): Promise> { - if (!options.dataElementsIds) return undefined; - const tempDataElementGroup: DataElementGroup = { - id: getUid("dataElementGroups", DE_DELETE_GROUP_CODE), - code: DE_DELETE_GROUP_CODE, - name: DE_DELETE_GROUP_CODE, - shortName: DE_DELETE_GROUP_CODE, - dataElements: options.dataElementsIds.map(dataElementId => ({ id: dataElementId })), - }; - const ids = [tempDataElementGroup.id]; - await this.d2DataElementGroup.save(ids, [tempDataElementGroup], [], { post: true }); - return tempDataElementGroup; - } - - private async exportSqlAuditDataElements(dataElements: Ref[]): Promise { - const dataElementsIds = dataElements.map(dataElement => `'${dataElement.id}'`).join(","); + private async exportSqlAuditDataElements(dataElementsIds: string[]): Promise { const sqlContent = `delete from datavalueaudit where dataelementid IN (select dataelementid from dataelement where uid IN (${dataElementsIds}));`; writeToDisk("audit_data_element.sql", sqlContent); } diff --git a/src/data/repositories/ImportDataElementSpreadSheetRepository.ts b/src/data/repositories/ImportDataElementSpreadSheetRepository.ts index 142ba7bf..0da58381 100644 --- a/src/data/repositories/ImportDataElementSpreadSheetRepository.ts +++ b/src/data/repositories/ImportDataElementSpreadSheetRepository.ts @@ -1,5 +1,5 @@ import _ from "lodash"; -import xlsx from "xlsx"; +import * as XLSX from "xlsx"; import { D2Api } from "../../types/d2-api"; import { Config } from "../../models/Config"; @@ -317,13 +317,13 @@ export class ImportDataElementSpreadSheetRepository implements ImportDataElement private getDataElementFromSheet(path: string): DataElementExcel[] { const sheet = this.getSheetOrThrow(path, "CreateUpdate"); - const excelRows = xlsx.utils.sheet_to_json(sheet); + const excelRows = XLSX.utils.sheet_to_json(sheet); return this.parseRecords(excelRows); } private getDataElementToRemoveFromSheet(path: string): Id[] { const sheet = this.getSheetOrThrow(path, "Delete"); - const excelRows = xlsx.utils.sheet_to_json(sheet); + const excelRows = XLSX.utils.sheet_to_json(sheet); return this.parseRecordsToRemove(excelRows); } @@ -332,7 +332,7 @@ export class ImportDataElementSpreadSheetRepository implements ImportDataElement } private getSheetOrThrow(path: string, sheetName: string) { - const excelFile = xlsx.readFile(path); + const excelFile = XLSX.readFile(path); const excelSheetName = excelFile.SheetNames.find(sn => sn === sheetName); if (!excelSheetName) throw Error(`Sheet not found: ${sheetName}`); const sheet = excelFile.Sheets[excelSheetName]; diff --git a/src/data/repositories/IndicatorReportD2Repository.ts b/src/data/repositories/IndicatorReportD2Repository.ts new file mode 100644 index 00000000..fd500df6 --- /dev/null +++ b/src/data/repositories/IndicatorReportD2Repository.ts @@ -0,0 +1,201 @@ +import _ from "lodash"; +import { D2Api, DataStore } from "../../types/d2-api"; +import { + IndicatorReport, + IndicatorReportToSave, + ProjectIndicatorRow, +} from "../../domain/entities/IndicatorReport"; +import { IndicatorReportRepository } from "../../domain/repositories/IndicatorReportRepository"; +import { DATA_MANAGEMENT_NAMESPACE } from "../common"; +import { Id, ISODateTimeString } from "../../domain/entities/Ref"; +import { D2ApiUbSettings } from "../common/D2ApiUbSettings"; +import { Config } from "../../models/Config"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import { D2DataElement } from "./D2DataElement"; +import { D2ApiProject } from "../common/D2ApiProject"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import { DataElement } from "../../domain/entities/DataElement"; + +export class IndicatorReportD2Repository implements IndicatorReportRepository { + private prefixKey = "ubreport"; + private dataStore: DataStore; + private d2ApiUbSettings: D2ApiUbSettings; + private d2ApiDataElement: D2DataElement; + private d2ApiProject: D2ApiProject; + + constructor(private api: D2Api, private config: Config) { + this.dataStore = this.api.dataStore(DATA_MANAGEMENT_NAMESPACE); + this.d2ApiUbSettings = new D2ApiUbSettings(this.api, config); + this.d2ApiDataElement = new D2DataElement(this.api, this.config); + this.d2ApiProject = new D2ApiProject(api, config); + } + + async getByCountry(countryId: Id): Promise { + const response = await this.dataStore.get(this.buildKey(countryId)).getData(); + return this.buildReportsFromResponse(response || [], countryId); + } + + async save(reports: IndicatorReportToSave[], countryId: Id): Promise { + const existingReports = await this.dataStore + .get(this.buildKey(countryId)) + .getData(); + + const reportsToSave = this.buildD2IndicatorReport(reports, existingReports || []); + + await this.dataStore.save(this.buildKey(countryId), reportsToSave).getData(); + } + + private buildD2IndicatorReport( + reports: IndicatorReportToSave[], + existingReports: D2Response[] + ) { + const currentDate = new Date().toISOString(); + return reports.map((report): D2Response => { + const existingRecord = existingReports?.find(item => + report.period.equalMonths(item.startDate, item.endDate) + ); + + return { + year: report.year, + countryId: report.countryId, + createdAt: existingRecord?.createdAt || currentDate, + updatedAt: currentDate, + endDate: report.period.endDateMonth, + startDate: report.period.startDateMonth, + projects: report.projects.map((project): D2Response["projects"][number] => ({ + id: project.id, + indicators: project.indicators.map(indicator => ({ + include: indicator.include, + indicatorId: indicator.indicatorId, + value: indicator.value || 0, + periodNotAvailable: indicator.periodNotAvailable, + })), + })), + }; + }); + } + + private async buildReportsFromResponse( + responses: D2Response[], + countryId: Id + ): Promise { + const projectResponse = await this.d2ApiProject.getAllProjectsByCountry(countryId, 1, []); + + const projectsIds = projectResponse.map(item => item.id); + const projects = await this.d2ApiProject.getByIds(projectsIds); + const settings = await this.d2ApiUbSettings.getAll({ projectsIds }); + const dataElementsIds = settings.flatMap(setting => setting.indicatorsIds); + const dataElements = await this.d2ApiDataElement.getByIds(dataElementsIds); + const settingsByProjects = settings.filter(setting => + projectsIds.includes(setting.projectId) + ); + const periods = settingsByProjects.flatMap(setting => setting.periods); + const groupedPeriods = UniqueBeneficiariesPeriod.uniquePeriodsByDates(periods); + + return responses.map(response => { + const currentPeriod = groupedPeriods.find(period => + period.equalMonths(response.startDate, response.endDate) + ); + if (!currentPeriod) + throw Error(`Period ${response.startDate}-${response.endDate} not found`); + + return IndicatorReport.create({ + year: response.year, + countryId: response.countryId, + createdAt: response.createdAt, + lastUpdatedAt: response.updatedAt, + period: currentPeriod, + projects: _(projects) + .map(project => { + const projectResult = response.projects.find( + item => item.id === project.id + ); + + return { + id: project.id, + indicators: projectResult?.indicators + ? this.buildExistingIndicators(projectResult, dataElements) + : this.buildIndicators( + settingsByProjects, + project.id, + dataElements + ), + project: { + id: project.id, + name: project.name, + openingDate: project.openingDate, + closedDate: project.closedDate, + }, + }; + }) + .compact() + .value(), + }); + }); + } + + private buildExistingIndicators( + projectResult: D2Response["projects"][number], + dataElements: DataElement[] + ): ProjectIndicatorRow[] { + return _(projectResult.indicators) + .map(indicator => { + const dataElementDetails = dataElements.find( + dataElement => dataElement.id === indicator.indicatorId + ); + return { + include: indicator.include, + indicatorId: indicator.indicatorId, + indicatorCode: dataElementDetails?.code || "", + indicatorName: dataElementDetails?.name || "", + value: indicator.value, + periodNotAvailable: indicator.periodNotAvailable, + }; + }) + .value(); + } + + private buildIndicators( + settings: UniqueBeneficiariesSettings[], + projectId: Id, + dataElements: DataElement[] + ): ProjectIndicatorRow[] { + const projectSettings = settings.find(setting => setting.projectId === projectId); + const indicatorsIds = projectSettings?.indicatorsIds || []; + return indicatorsIds.map((indicatorId): ProjectIndicatorRow => { + const dataElementDetails = dataElements.find( + dataElement => dataElement.id === indicatorId + ); + return { + include: false, + indicatorCode: dataElementDetails?.code || "", + indicatorName: dataElementDetails?.name || "", + indicatorId: indicatorId, + value: 0, + periodNotAvailable: false, + }; + }); + } + + private buildKey(countryId: Id) { + return `${this.prefixKey}-${countryId}`; + } +} + +type D2Response = { + countryId: Id; + startDate: number; + endDate: number; + createdAt: ISODateTimeString; + updatedAt: ISODateTimeString; + year: number; + projects: Array<{ + id: Id; + indicators: Array<{ + include: boolean; + indicatorId: Id; + value: number; + periodNotAvailable: boolean; + }>; + }>; +}; diff --git a/src/data/repositories/ProjectD2Repository.ts b/src/data/repositories/ProjectD2Repository.ts new file mode 100644 index 00000000..688375d4 --- /dev/null +++ b/src/data/repositories/ProjectD2Repository.ts @@ -0,0 +1,23 @@ +import { Id } from "../../domain/entities/Ref"; +import { ProjectRepository } from "../../domain/repositories/ProjectRepository"; +import { Config } from "../../models/Config"; +import Project from "../../models/Project"; +import { ProjectForList } from "../../models/ProjectsList"; +import { D2Api } from "../../types/d2-api"; +import { D2ApiProject } from "../common/D2ApiProject"; + +export class ProjectD2Repository implements ProjectRepository { + private d2ApiProject: D2ApiProject; + + constructor(private api: D2Api, private config: Config) { + this.d2ApiProject = new D2ApiProject(api, config); + } + + async getById(id: Id): Promise { + return Project.get(this.api, this.config, id); + } + + async getByCountries(countryId: Id): Promise { + return this.d2ApiProject.getAllProjectsByCountry(countryId, 1, []); + } +} diff --git a/src/data/repositories/UniqueBeneficiariesSettingsD2Repository.ts b/src/data/repositories/UniqueBeneficiariesSettingsD2Repository.ts new file mode 100644 index 00000000..4f93378a --- /dev/null +++ b/src/data/repositories/UniqueBeneficiariesSettingsD2Repository.ts @@ -0,0 +1,29 @@ +import { Id } from "../../domain/entities/Ref"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import { UniqueBeneficiariesSettingsRepository } from "../../domain/repositories/UniqueBeneficiariesSettingsRepository"; +import { Config } from "../../models/Config"; +import { D2Api } from "../../types/d2-api"; +import { Maybe } from "../../types/utils"; +import { D2ApiUbSettings } from "../common/D2ApiUbSettings"; + +export class UniqueBeneficiariesSettingsD2Repository + implements UniqueBeneficiariesSettingsRepository +{ + private d2ApiUbSettings: D2ApiUbSettings; + + constructor(private api: D2Api, private config: Config) { + this.d2ApiUbSettings = new D2ApiUbSettings(this.api, this.config); + } + + async getAll(options: { projectsIds: Maybe }): Promise { + return this.d2ApiUbSettings.getAll(options); + } + + async get(projectId: Id): Promise { + return this.d2ApiUbSettings.get(projectId); + } + + async save(settings: UniqueBeneficiariesSettings): Promise { + await this.d2ApiUbSettings.save(settings); + } +} diff --git a/src/data/repositories/UniquePeriodD2Repository.ts b/src/data/repositories/UniquePeriodD2Repository.ts new file mode 100644 index 00000000..f7d7cf80 --- /dev/null +++ b/src/data/repositories/UniquePeriodD2Repository.ts @@ -0,0 +1,57 @@ +import { D2Api, DataStore } from "../../types/d2-api"; +import { + UniqueBeneficiariesPeriod, + UniqueBeneficiariesPeriodsAttrs, +} from "../../domain/entities/UniqueBeneficiariesPeriod"; +import { UniquePeriodRepository } from "../../domain/repositories/UniquePeriodRepository"; +import { DATA_MANAGEMENT_NAMESPACE } from "../common"; +import { Id } from "../../domain/entities/Ref"; +import { D2ApiUbSettings } from "../common/D2ApiUbSettings"; +import { generateUid } from "d2/uid"; +import { Config } from "../../models/Config"; + +export class UniquePeriodD2Repository implements UniquePeriodRepository { + private dataStore: DataStore; + private namespace = DATA_MANAGEMENT_NAMESPACE; + private d2ApiUbSettings: D2ApiUbSettings; + + constructor(private api: D2Api, config: Config) { + this.dataStore = this.api.dataStore(this.namespace); + this.d2ApiUbSettings = new D2ApiUbSettings(this.api, config); + } + + async getByProject(projectId: Id): Promise { + const settings = await this.d2ApiUbSettings.get(projectId); + return settings.periods; + } + + async save(projectId: Id, periods: UniqueBeneficiariesPeriod[]): Promise { + const d2Response = await this.dataStore + .get(this.d2ApiUbSettings.buildKeyId(projectId)) + .getData(); + + await this.dataStore + .save(this.buildKeyId(projectId), { + ...d2Response, + uniqueBeneficiaries: { + ...(d2Response?.uniqueBeneficiaries || {}), + periods: UniqueBeneficiariesPeriod.buildPeriods(periods, generateUid()), + }, + }) + .getData(); + } + + private getProjectKey() { + return "project-"; + } + + private buildKeyId(projectId: Id) { + return `${this.getProjectKey()}${projectId}`; + } +} + +type D2PeriodResponse = { + uniqueBeneficiaries: { + periods: Omit[]; + }; +}; diff --git a/src/domain/entities/IndicatorCalculation.ts b/src/domain/entities/IndicatorCalculation.ts new file mode 100644 index 00000000..11eda74c --- /dev/null +++ b/src/domain/entities/IndicatorCalculation.ts @@ -0,0 +1,122 @@ +import _ from "lodash"; +import { Maybe } from "../../types/utils"; +import { DataElement } from "./DataElement"; +import { DataValue } from "./DataValue"; +import { Either } from "./generic/Either"; +import { ValidationError } from "./generic/Errors"; +import { Struct } from "./generic/Struct"; +import { isPositive, validateRequired } from "./generic/Validations"; +import { Code, Id } from "./Ref"; + +export type IndicatorCalculationKeys = keyof IndicatorCalculationAttrs; +export type IndicatorCalculationAttrs = { + id: Id; + newValue: number; + editableNewValue: Maybe; + returningValue: Maybe; + comment: string; + previousValue?: number; + nextValue?: number; + code: Code; + name: string; +}; + +export class IndicatorCalculation extends Struct() { + static getTotal(data: IndicatorCalculationAttrs): number { + return this.calculateTotalValue(data.editableNewValue, data.returningValue); + } + + static hasChanged(data: IndicatorCalculationAttrs): boolean { + return data.nextValue !== undefined; + } + + static build( + data: IndicatorCalculationAttrs + ): Either[], IndicatorCalculation> { + const errors = this.checkDataAndGetErrors(data); + if (errors.length > 0) return Either.error(errors); + + return Either.success(IndicatorCalculation.create(data)); + } + + static checkDataAndGetErrors( + data: IndicatorCalculationAttrs + ): ValidationError[] { + const idProperty: ValidationError = { + property: "id", + errors: validateRequired(data.id), + value: data.id, + }; + + const newProperty: ValidationError = { + property: "newValue", + errors: [...isPositive(data.newValue)], + value: data.newValue, + }; + + const editableNewValueProperty: ValidationError = { + property: "editableNewValue", + errors: data.editableNewValue ? [...isPositive(data.editableNewValue)] : [], + value: data.editableNewValue, + }; + + const returningProperty: ValidationError = { + property: "returningValue", + errors: data.returningValue ? [...isPositive(data.returningValue)] : [], + value: data.returningValue, + }; + + const errors: ValidationError[] = _([ + idProperty, + newProperty, + editableNewValueProperty, + returningProperty, + ]) + .filter(validation => validation.errors.length > 0) + .value(); + + return errors; + } + + static updateValuesById( + id: Id, + existingRecord: Maybe, + dataValues: DataValue[], + details: Pick, + verifyChangesInValues: boolean + ): IndicatorCalculation { + const newValueSum = _(dataValues) + .filter(dataValue => dataValue.dataElement === id) + .sumBy(dataValue => Number(dataValue.value || 0)); + + const returningValue = existingRecord?.returningValue; + + const newValueHasChanged = verifyChangesInValues + ? existingRecord?.newValue !== newValueSum + : false; + + return IndicatorCalculation.build({ + id: id, + newValue: newValueSum, + editableNewValue: newValueHasChanged + ? newValueSum + : existingRecord?.editableNewValue ?? newValueSum, + returningValue, + comment: existingRecord?.comment || "", + previousValue: newValueHasChanged ? existingRecord?.newValue : undefined, + nextValue: newValueHasChanged ? newValueSum : undefined, + code: details.code, + name: details.name, + }).get(); + } + + static commentIsRequired(attrs: IndicatorCalculationAttrs): boolean { + const entity = IndicatorCalculation.create(attrs); + const newAndTotalAreDifferent = IndicatorCalculation.getTotal(entity) !== entity.newValue; + return newAndTotalAreDifferent && entity.comment.length === 0; + } + + static calculateTotalValue(editable: Maybe, returning: Maybe): number { + return (editable ?? 0) + (returning ?? 0); + } +} diff --git a/src/domain/entities/IndicatorReport.tsx b/src/domain/entities/IndicatorReport.tsx new file mode 100644 index 00000000..b7ee97ec --- /dev/null +++ b/src/domain/entities/IndicatorReport.tsx @@ -0,0 +1,67 @@ +import { ProjectForList } from "../../models/ProjectsList"; +import { Maybe } from "../../types/utils"; +import { Struct } from "./generic/Struct"; +import { Code, Id, ISODateTimeString } from "./Ref"; +import { UniqueBeneficiariesPeriod } from "./UniqueBeneficiariesPeriod"; + +export type IndicatorReportAttrs = { + period: UniqueBeneficiariesPeriod; + countryId: Id; + createdAt: ISODateTimeString; + lastUpdatedAt: ISODateTimeString; + projects: ProjectRows[]; + year: number; +}; + +export type IndicatorReportToSave = Omit; + +export type ProjectRows = { + id: Id; + project: ProjectCountry; + indicators: ProjectIndicatorRow[]; +}; + +export type ProjectCountry = Pick; + +export type ProjectIndicatorRow = { + indicatorId: Id; + indicatorCode: Code; + indicatorName: string; + value: Maybe; + include: boolean; + periodNotAvailable: boolean; +}; + +export class IndicatorReport extends Struct() { + updateProjectIndicators(projectId: Id, indicatorId: Id, include: boolean): IndicatorReport { + const newProjects = this.projects.map(project => { + if (project.id !== projectId) return project; + + return project.id === projectId + ? { + ...project, + indicators: this.updateIndicators(project, indicatorId, include), + } + : project; + }); + return this._update({ projects: newProjects }); + } + + checkPeriodAndYear(periodId: Id, year: number): boolean { + return this.period.id === periodId && this.year === year; + } + + private updateIndicators( + project: ProjectRows, + indicatorId: Id, + include: boolean + ): ProjectIndicatorRow[] { + return project.indicators.map(indicator => + indicator.indicatorId === indicatorId ? { ...indicator, include } : indicator + ); + } + + static generateIndicatorFullName(indicator: ProjectIndicatorRow): string { + return `${indicator.indicatorName} (${indicator.indicatorCode})`; + } +} diff --git a/src/domain/entities/IndicatorValidation.ts b/src/domain/entities/IndicatorValidation.ts new file mode 100644 index 00000000..d8d88a51 --- /dev/null +++ b/src/domain/entities/IndicatorValidation.ts @@ -0,0 +1,111 @@ +import _ from "lodash"; +import { getYearsFromProject } from "../../pages/project-indicators-validation/ProjectIndicatorsValidation"; +import { Maybe } from "../../types/utils"; +import { Either } from "./generic/Either"; +import { ValidationError } from "./generic/Errors"; +import { Struct } from "./generic/Struct"; +import { validateRequired } from "./generic/Validations"; +import { IndicatorCalculation } from "./IndicatorCalculation"; +import { ProjectCountry } from "./IndicatorReport"; +import { Id, ISODateTimeString } from "./Ref"; +import { UniqueBeneficiariesPeriod } from "./UniqueBeneficiariesPeriod"; + +export type IndicatorValidationAttrs = { + period: UniqueBeneficiariesPeriod; + year: number; + createdAt: ISODateTimeString; + lastUpdatedAt: Maybe; + indicatorsCalculation: IndicatorCalculation[]; +}; + +export class IndicatorValidation extends Struct() { + static build( + attrs: IndicatorValidationAttrs + ): Either[], IndicatorValidation> { + const errors = this.checkDataAndGetErrors(attrs); + if (errors.length > 0) { + return Either.error(errors); + } + return Either.success(IndicatorValidation.create(attrs)); + } + + private static checkDataAndGetErrors( + data: IndicatorValidationAttrs + ): ValidationError[] { + const periodProperty: ValidationError = { + property: "period", + errors: validateRequired(data.period), + value: data.period, + }; + + const errors = [periodProperty].filter(validation => validation.errors.length > 0); + + return errors; + } + + static validateCommentIndicators(indicators: IndicatorCalculation[]): boolean { + return indicators.some(indicator => IndicatorCalculation.commentIsRequired(indicator)); + } + + static buildIndicatorsValidationFromPeriods( + periods: UniqueBeneficiariesPeriod[], + indicatorsIds: Id[], + project: ProjectCountry + ): IndicatorValidation[] { + const { periodsKeys, periodsByYears } = this.getPeriodsAndYearsFromDates( + project.openingDate, + project.closedDate, + periods + ); + + return periodsKeys.map(periodYearKey => { + const { period, year } = periodsByYears[periodYearKey]; + return IndicatorValidation.build({ + createdAt: "", + lastUpdatedAt: "", + period, + year, + indicatorsCalculation: indicatorsIds.map(indicatorId => { + return IndicatorCalculation.build({ + id: indicatorId, + newValue: 0, + editableNewValue: undefined, + returningValue: undefined, + comment: "", + code: "", + name: "", + }).get(); + }), + }).get(); + }); + } + + checkPeriodAndYear(periodId: Id, year: number): boolean { + return this.period.id === periodId && this.year === year; + } + + static getPeriodsAndYearsFromDates( + startDate: ISODateTimeString, + endDate: ISODateTimeString, + periods: UniqueBeneficiariesPeriod[] + ) { + const years = getYearsFromProject(startDate, endDate); + return this.groupPeriodsAndYears(years, periods); + } + + static groupPeriodsAndYears(years: number[], periods: UniqueBeneficiariesPeriod[]) { + const periodsByYears = _(years) + .flatMap(year => + periods.map(period => ({ + id: `${year}-${period.id}`, + value: { period: period, year }, + })) + ) + .keyBy(item => item.id) + .mapValues(item => item.value) + .value(); + + const periodsKeys = Object.keys(periodsByYears); + return { periodsByYears, periodsKeys }; + } +} diff --git a/src/domain/entities/Ref.ts b/src/domain/entities/Ref.ts index 14f8fdef..9c1fdb1a 100644 --- a/src/domain/entities/Ref.ts +++ b/src/domain/entities/Ref.ts @@ -9,3 +9,4 @@ export interface NamedRef extends Ref { } export type Code = string; +export type ISODateTimeString = string; diff --git a/src/domain/entities/UniqueBeneficiariesPeriod.ts b/src/domain/entities/UniqueBeneficiariesPeriod.ts new file mode 100644 index 00000000..c8cc4e01 --- /dev/null +++ b/src/domain/entities/UniqueBeneficiariesPeriod.ts @@ -0,0 +1,187 @@ +import _ from "lodash"; +import { Maybe } from "../../types/utils"; +import { getMonthNameFromNumber } from "../../utils/date"; +import { getUid } from "../../utils/dhis2"; + +import { Either } from "./generic/Either"; +import { ValidationError, ValidationErrorKey } from "./generic/Errors"; +import { Struct } from "./generic/Struct"; +import { + betweenValue, + periodsTypes, + validatePeriodType, + validateRequired, +} from "./generic/Validations"; +import { Id } from "./Ref"; + +export type PeriodType = typeof periodsTypes[number]; + +export type UniqueBeneficiariesPeriodsAttrs = { + id: string; + name: string; + type: PeriodType; + startDateMonth: number; + endDateMonth: number; + projectId?: Id; +}; + +export class UniqueBeneficiariesPeriod extends Struct() { + static build( + data: UniqueBeneficiariesPeriodsAttrs + ): Either[], UniqueBeneficiariesPeriod> { + const errors = this.checkDataAndGetErrors(data); + if (errors.length > 0) { + return Either.error(errors); + } + return Either.success(UniqueBeneficiariesPeriod.create(data)); + } + + public static defaultPeriods(): UniqueBeneficiariesPeriod[] { + const yearlyPeriod = UniqueBeneficiariesPeriod.create({ + id: "annual", + name: "Annual", + type: "ANNUAL", + startDateMonth: 1, + endDateMonth: 12, + }); + const semiAnnualPeriod = UniqueBeneficiariesPeriod.create({ + id: "semi-annual", + name: "Semi-annual", + type: "SEMIANNUAL", + startDateMonth: 1, + endDateMonth: 6, + }); + + return [semiAnnualPeriod, yearlyPeriod]; + } + + isProtected(): boolean { + return this.id === "annual" || this.id === "semi-annual"; + } + + public static initialPeriodData(): UniqueBeneficiariesPeriod { + return this.create({ + endDateMonth: 12, + id: getUid("unique_beneficiaries_period", new Date().getTime().toString()), + name: "", + startDateMonth: 1, + type: "CUSTOM", + }); + } + + public static validate(data: UniqueBeneficiariesPeriodsAttrs): { + isValid: boolean; + errorMessage: string; + } { + const errors = this.checkDataAndGetErrors(data).filter( + validation => validation.errors.length > 0 + ); + return { + isValid: errors.length === 0, + errorMessage: errors.map(error => error.errors.join(", ")).join(", "), + }; + } + + private static checkDataAndGetErrors( + data: UniqueBeneficiariesPeriodsAttrs + ): ValidationError[] { + const dateDifferentError: ValidationErrorKey[] = + data.startDateMonth > data.endDateMonth || data.startDateMonth === data.endDateMonth + ? ["invalid_period_date_range"] + : []; + + const errors: ValidationError[] = _([ + { + property: "name" as const, + errors: validateRequired(data.name), + value: data.name, + }, + { + property: "type" as const, + errors: validatePeriodType(data.type), + value: data.type, + }, + { + property: "startDateMonth" as const, + errors: betweenValue(data.startDateMonth, 1, 12), + value: data.startDateMonth, + }, + { + property: "startDateMonth" as const, + errors: betweenValue(data.startDateMonth, 1, 12), + value: data.startDateMonth, + }, + { + property: "startDateMonth" as const, + errors: dateDifferentError, + value: data.startDateMonth, + }, + ]) + .filter(validation => validation.errors.length > 0) + .value(); + + return errors; + } + + public static uniquePeriodsByDates( + periods: UniqueBeneficiariesPeriod[] + ): UniqueBeneficiariesPeriod[] { + const combinedPeriods = _(periods) + .groupBy(period => `${period.startDateMonth}-${period.endDateMonth}`) + .map((group, key): Maybe => { + const [startDateMonth, endDateMonth] = key.split("-").map(Number); + const startMonthName = getMonthNameFromNumber(startDateMonth); + const endMonthName = getMonthNameFromNumber(endDateMonth); + const joinNames = group.map(period => period.name).join(", "); + + const isAnnualOrSemiAnnual = group.some( + period => period.type === "ANNUAL" || period.type === "SEMIANNUAL" + ); + + if (isAnnualOrSemiAnnual) return undefined; + + return UniqueBeneficiariesPeriod.create({ + id: group[0].id || "", + name: `${startMonthName} - ${endMonthName} (${joinNames})`, + type: "CUSTOM", + startDateMonth, + endDateMonth, + }); + }) + .compact() + .value(); + + return _(combinedPeriods) + .concat(this.defaultPeriods()) + .uniqBy(period => period.id) + .value(); + } + + public equalMonths(startDateMonth: number, endDateMonth: number): boolean { + return this.startDateMonth === startDateMonth && this.endDateMonth === endDateMonth; + } + + static buildPeriods( + periods: UniqueBeneficiariesPeriod[], + newId: Id + ): UniqueBeneficiariesPeriod[] { + const periodsWithIds = this.validateIdsInPeriods(periods, newId); + return this.excludeDefaultPeriods(periodsWithIds); + } + + static validateIdsInPeriods( + periods: UniqueBeneficiariesPeriod[], + newId: Id + ): UniqueBeneficiariesPeriod[] { + return periods.map(period => { + if (!period.id) { + return UniqueBeneficiariesPeriod.build({ ...period, id: newId }).get(); + } + return period; + }); + } + + static excludeDefaultPeriods(periods: UniqueBeneficiariesPeriod[]) { + return periods.filter(period => !period.isProtected()); + } +} diff --git a/src/domain/entities/UniqueBeneficiariesSettings.ts b/src/domain/entities/UniqueBeneficiariesSettings.ts new file mode 100644 index 00000000..eebfcc66 --- /dev/null +++ b/src/domain/entities/UniqueBeneficiariesSettings.ts @@ -0,0 +1,10 @@ +import { IndicatorValidation } from "./IndicatorValidation"; +import { Id } from "./Ref"; +import { UniqueBeneficiariesPeriod } from "./UniqueBeneficiariesPeriod"; + +export type UniqueBeneficiariesSettings = { + projectId: Id; + periods: UniqueBeneficiariesPeriod[]; + indicatorsIds: Id[]; + indicatorsValidation: IndicatorValidation[]; +}; diff --git a/src/domain/entities/generic/Either.ts b/src/domain/entities/generic/Either.ts new file mode 100644 index 00000000..626f1588 --- /dev/null +++ b/src/domain/entities/generic/Either.ts @@ -0,0 +1,105 @@ +/** + * Either a success value or an error. Example: + * + * ``` + * Either.success<{ message: string }, string>("9") + * .map(s => parseInt(s)) + * .flatMap(x => { + * return x > 0 ? Either.success(Math.sqrt(x)) : Either.error({ message: "negative!" }); + * }) + * .match({ + * success: x => console.log(`Value is ${x}`), + * error: error => console.error(`Some error: ${error.message}`), + * }); // prints `Value is 3` + * ``` + */ + +export class Either { + constructor(public value: EitherValue) {} + + match(matchObj: MatchObject): Res { + switch (this.value.type) { + case "success": + return matchObj.success(this.value.data); + case "error": + return matchObj.error(this.value.error); + } + } + + isError(): this is this & { value: EitherValueError } { + return this.value.type === "error"; + } + + isSuccess(): this is this & { value: EitherValueSuccess } { + return this.value.type === "success"; + } + + map(fn: (data: Data) => Data1): Either { + return this.flatMap(data => new Either({ type: "success", data: fn(data) })); + } + + mapError(fn: (error: Error) => Error1): Either { + return this.flatMapError( + error => new Either({ type: "error", error: fn(error) }) + ); + } + + flatMap(fn: (data: Data) => Either): Either { + return this.match({ + success: data => fn(data), + error: () => this as Either, + }); + } + + flatMapError(fn: (error: Error) => Either): Either { + return this.match({ + success: () => this as Either, + error: error => fn(error), + }); + } + + static error(error: Error) { + return new Either({ type: "error", error }); + } + + static success(data: Data) { + return new Either({ type: "success", data }); + } + + static map2( + [either1, either2]: [Either, Either], + fn: (data1: Data1, data2: Data2) => Res + ): Either { + return either1.flatMap(data1 => { + return either2.map(data2 => fn(data1, data2)); + }); + } + + get(errorMessage?: string): Data { + return this.getOrThrow(errorMessage); + } + + getOrThrow(errorMessage?: string): Data { + const throwFn = () => { + throw Error( + errorMessage + ? errorMessage + : "An error has ocurred retrieving value: " + JSON.stringify(this.value) + ); + }; + + return this.match({ + error: () => throwFn(), + success: value => value, + }); + } +} + +type EitherValueError = { type: "error"; error: Error; data?: never }; +type EitherValueSuccess = { type: "success"; error?: never; data: Data }; +type EitherValue = EitherValueError | EitherValueSuccess; + +type MatchObject = { + success: (data: Data) => Res; + error: (error: Error) => Res; +}; diff --git a/src/domain/entities/generic/Errors.ts b/src/domain/entities/generic/Errors.ts new file mode 100644 index 00000000..d9f836fa --- /dev/null +++ b/src/domain/entities/generic/Errors.ts @@ -0,0 +1,46 @@ +import i18n from "../../../locales"; + +export type ValidationErrorKey = + | "field_cannot_be_blank" + | "not_in_list" + | "positive_number" + | "invalid_period_date_range"; + +export const validationErrorMessages: Record< + ValidationErrorKey, + (fieldName: string, value: unknown) => string +> = { + field_cannot_be_blank: (fieldName: string) => + i18n.t(`Cannot be blank: {{fieldName}}`, { fieldName: fieldName, nsSeparator: false }), + not_in_list: (fieldName: string, value: unknown) => + i18n.t(`{{value}} is not a valid value for {{fieldName}}`, { + fieldName: fieldName, + value: value, + nsSeparator: false, + }), + positive_number: (fieldName: string) => { + return i18n.t(`{{fieldName}} must be a positive number`, { + fieldName: fieldName, + }); + }, + invalid_period_date_range: () => { + return i18n.t(`Start date must be before end date`); + }, +}; + +export function getErrors(errors: ValidationError[]) { + return errors + .map(error => { + return error.errors.map(err => + validationErrorMessages[err](error.property as string, error.value) + ); + }) + .flat() + .join("\n"); +} + +export type ValidationError = { + property: keyof T; + value: unknown; + errors: ValidationErrorKey[]; +}; diff --git a/src/domain/entities/generic/Validations.ts b/src/domain/entities/generic/Validations.ts new file mode 100644 index 00000000..2adee389 --- /dev/null +++ b/src/domain/entities/generic/Validations.ts @@ -0,0 +1,22 @@ +import { PeriodType } from "../UniqueBeneficiariesPeriod"; +import { ValidationErrorKey } from "./Errors"; + +export const periodsTypes = ["CUSTOM", "ANNUAL", "SEMIANNUAL"] as const; + +export function validateRequired(value: any): ValidationErrorKey[] { + const isBlank = !value || (value.length !== undefined && value.length === 0); + + return isBlank ? ["field_cannot_be_blank"] : []; +} + +export function validatePeriodType(periodType: PeriodType): ValidationErrorKey[] { + return periodsTypes.includes(periodType) ? [] : ["not_in_list"]; +} + +export function betweenValue(value: number, from: number, to: number): ValidationErrorKey[] { + return value >= from && value <= to ? [] : ["not_in_list"]; +} + +export function isPositive(value: number): ValidationErrorKey[] { + return value >= 0 ? [] : ["positive_number"]; +} diff --git a/src/domain/repositories/DataValueRepository.ts b/src/domain/repositories/DataValueRepository.ts index 5b9c12f1..0097d9e2 100644 --- a/src/domain/repositories/DataValueRepository.ts +++ b/src/domain/repositories/DataValueRepository.ts @@ -8,10 +8,12 @@ export interface DataValueRepository { } export interface GetDataValueOptions { + dataSetIds: Maybe; orgUnitIds: Id[]; children: boolean; includeDeleted: boolean; startDate: string; endDate: string; dataElementsIds: Maybe; + logDataElements: boolean; } diff --git a/src/domain/repositories/IndicatorReportRepository.ts b/src/domain/repositories/IndicatorReportRepository.ts new file mode 100644 index 00000000..3d713314 --- /dev/null +++ b/src/domain/repositories/IndicatorReportRepository.ts @@ -0,0 +1,7 @@ +import { IndicatorReport, IndicatorReportToSave } from "../entities/IndicatorReport"; +import { Id } from "../entities/Ref"; + +export interface IndicatorReportRepository { + getByCountry(countryId: Id): Promise; + save(reports: IndicatorReportToSave[], countryId: Id): Promise; +} diff --git a/src/domain/repositories/ProjectRepository.ts b/src/domain/repositories/ProjectRepository.ts new file mode 100644 index 00000000..bd1e748f --- /dev/null +++ b/src/domain/repositories/ProjectRepository.ts @@ -0,0 +1,8 @@ +import Project from "../../models/Project"; +import { ProjectForList } from "../../models/ProjectsList"; +import { Id } from "../entities/Ref"; + +export interface ProjectRepository { + getById(id: Id): Promise; + getByCountries(countryId: Id): Promise; +} diff --git a/src/domain/repositories/UniqueBeneficiariesSettingsRepository.ts b/src/domain/repositories/UniqueBeneficiariesSettingsRepository.ts new file mode 100644 index 00000000..cb996836 --- /dev/null +++ b/src/domain/repositories/UniqueBeneficiariesSettingsRepository.ts @@ -0,0 +1,9 @@ +import { Maybe } from "../../types/utils"; +import { Id } from "../entities/Ref"; +import { UniqueBeneficiariesSettings } from "../entities/UniqueBeneficiariesSettings"; + +export interface UniqueBeneficiariesSettingsRepository { + get(projectId: Id): Promise; + save(settings: UniqueBeneficiariesSettings): Promise; + getAll(options: { projectsIds: Maybe }): Promise; +} diff --git a/src/domain/repositories/UniquePeriodRepository.ts b/src/domain/repositories/UniquePeriodRepository.ts new file mode 100644 index 00000000..4c2174be --- /dev/null +++ b/src/domain/repositories/UniquePeriodRepository.ts @@ -0,0 +1,7 @@ +import { Id } from "../entities/Ref"; +import { UniqueBeneficiariesPeriod } from "../entities/UniqueBeneficiariesPeriod"; + +export interface UniquePeriodRepository { + getByProject(projectId: Id): Promise; + save(projectId: Id, periods: UniqueBeneficiariesPeriod[]): Promise; +} diff --git a/src/domain/usecases/GetIndicatorsValidationUseCase.ts b/src/domain/usecases/GetIndicatorsValidationUseCase.ts new file mode 100644 index 00000000..d1c3c468 --- /dev/null +++ b/src/domain/usecases/GetIndicatorsValidationUseCase.ts @@ -0,0 +1,157 @@ +import i18n from "../../locales"; +import { promiseMap } from "../../migrations/utils"; +import { Config } from "../../models/Config"; +import Project from "../../models/Project"; +import { DataValue } from "../entities/DataValue"; +import { IndicatorCalculation } from "../entities/IndicatorCalculation"; +import { IndicatorValidation } from "../entities/IndicatorValidation"; +import { Code, Id, Ref } from "../entities/Ref"; +import { UniqueBeneficiariesPeriod } from "../entities/UniqueBeneficiariesPeriod"; +import { UniqueBeneficiariesSettings } from "../entities/UniqueBeneficiariesSettings"; +import { DataValueRepository } from "../repositories/DataValueRepository"; +import { ProjectRepository } from "../repositories/ProjectRepository"; +import { UniqueBeneficiariesSettingsRepository } from "../repositories/UniqueBeneficiariesSettingsRepository"; + +export class GetIndicatorsValidationUseCase { + private actualCombination: Ref & { displayName: string }; + + constructor( + private dataValueRepository: DataValueRepository, + private uniqueBeneficiariesSettingsRepository: UniqueBeneficiariesSettingsRepository, + private projectRepository: ProjectRepository, + private config: Config + ) { + this.actualCombination = this.config.categoryOptionCombos.actual; + } + + async execute(options: GetIndicatorsOptions): Promise { + const [settings, project] = await Promise.all([ + this.getSettings(options.projectId), + this.getProjectById(options.projectId), + ]); + + if (settings.indicatorsIds.length === 0) + throw Error(i18n.t("No unique indicators selected")); + + return this.getIndicatorsWithValues(project, settings); + } + + private async getSettings(projectId: Id): Promise { + return this.uniqueBeneficiariesSettingsRepository.get(projectId); + } + + private async getProjectById(projectId: Id): Promise { + return this.projectRepository.getById(projectId); + } + + private async getIndicatorsWithValues( + project: Project, + settings: UniqueBeneficiariesSettings + ): Promise { + const dataSetId = project.dataSets?.actual.id; + if (!dataSetId) throw new Error(`Actual dataSet not found for project: ${project.name}`); + + const { periodsKeys, periodsByYears } = IndicatorValidation.getPeriodsAndYearsFromDates( + project.startDate?.toISOString() || "", + project.endDate?.toISOString() || "", + settings.periods + ); + + const indicatorsDetails = project.uniqueIndicators.get({ onlySelected: true }); + + const indicatorsWithValues = await promiseMap(periodsKeys, periodYearKey => { + const { period, year } = periodsByYears[periodYearKey]; + return this.setValuesToIndicators( + period, + dataSetId, + project.id, + settings, + indicatorsDetails, + year + ); + }); + + return indicatorsWithValues; + } + + private async setValuesToIndicators( + period: UniqueBeneficiariesPeriod, + dataSetId: Id, + projectId: Id, + settings: UniqueBeneficiariesSettings, + indicatorsDetails: Array<{ id: Id; name: string; code: Code }>, + year: number + ): Promise { + const dateRange = this.getDatesRange(period, year); + const dataValues = await this.getDataValues(dataSetId, projectId, settings, dateRange); + const actualDataValues = this.getOnlyActualDataValues(dataValues); + const indicatorCalculation = settings.indicatorsValidation.find(item => + item.checkPeriodAndYear(period.id, year) + ); + + const indicatorCalculations = settings.indicatorsIds.map(indicatorId => { + const existingRecord = indicatorCalculation?.indicatorsCalculation.find( + item => item.id === indicatorId + ); + const details = indicatorsDetails.find(item => item.id === indicatorId); + if (!details) throw new Error(`Indicator details not found for id: ${indicatorId}`); + return IndicatorCalculation.updateValuesById( + indicatorId, + existingRecord, + actualDataValues, + details, + Boolean(indicatorCalculation?.createdAt) + ); + }); + + return IndicatorValidation.build({ + createdAt: indicatorCalculation?.createdAt || "", + lastUpdatedAt: indicatorCalculation?.lastUpdatedAt, + period: period, + indicatorsCalculation: indicatorCalculations, + year: year, + }).get(); + } + + private async getDataValues( + dataSetId: Id, + projectId: Id, + settings: UniqueBeneficiariesSettings, + dateRange: DateRange + ) { + const { startDate, endDate } = dateRange; + return this.dataValueRepository.get({ + dataSetIds: [dataSetId], + orgUnitIds: [projectId], + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + children: false, + dataElementsIds: settings.indicatorsIds, + includeDeleted: false, + logDataElements: false, + }); + } + + private getDatesRange(period: UniqueBeneficiariesPeriod, year: number): DateRange { + const startDate = this.getMonthDay(period.startDateMonth, "first", year); + const endDate = this.getMonthDay(period.endDateMonth, "last", year); + return { startDate, endDate }; + } + + private getOnlyActualDataValues(dataValues: DataValue[]): DataValue[] { + return dataValues.filter( + dataValue => dataValue.attributeOptionCombo === this.actualCombination.id + ); + } + + private getMonthDay(month: number, option: "first" | "last", year: number): Date { + if (month < 1 || month > 12) { + throw new Error(`Invalid month number: ${month}`); + } + + return option === "first" ? new Date(year, month - 1, 1) : new Date(year, month, 0); + } +} + +type GetIndicatorsOptions = { projectId: Id }; +type DateRange = { startDate: Date; endDate: Date }; diff --git a/src/domain/usecases/GetProjectByIdUseCase.ts b/src/domain/usecases/GetProjectByIdUseCase.ts new file mode 100644 index 00000000..548966d6 --- /dev/null +++ b/src/domain/usecases/GetProjectByIdUseCase.ts @@ -0,0 +1,11 @@ +import Project from "../../models/Project"; +import { Id } from "../entities/Ref"; +import { ProjectRepository } from "../repositories/ProjectRepository"; + +export class GetProjectByIdUseCase { + constructor(private projectRepository: ProjectRepository) {} + + execute(id: Id): Promise { + return this.projectRepository.getById(id); + } +} diff --git a/src/domain/usecases/GetProjectsByCountryUseCase.ts b/src/domain/usecases/GetProjectsByCountryUseCase.ts new file mode 100644 index 00000000..9e17d3ba --- /dev/null +++ b/src/domain/usecases/GetProjectsByCountryUseCase.ts @@ -0,0 +1,272 @@ +import _ from "lodash"; +import { checkProjectDateIsInYear } from "../../models/Project"; +import { ProjectForList } from "../../models/ProjectsList"; +import { getYearsFromProject } from "../../pages/project-indicators-validation/ProjectIndicatorsValidation"; +import { Maybe } from "../../types/utils"; +import { getId } from "../../utils/dhis2"; +import { DataElement } from "../entities/DataElement"; +import { IndicatorCalculation } from "../entities/IndicatorCalculation"; +import { + IndicatorReport, + ProjectCountry, + ProjectIndicatorRow, + ProjectRows, +} from "../entities/IndicatorReport"; +import { IndicatorValidation } from "../entities/IndicatorValidation"; +import { Id } from "../entities/Ref"; +import { UniqueBeneficiariesPeriod } from "../entities/UniqueBeneficiariesPeriod"; +import { UniqueBeneficiariesSettings } from "../entities/UniqueBeneficiariesSettings"; +import { DataElementRepository } from "../repositories/DataElementRepository"; +import { IndicatorReportRepository } from "../repositories/IndicatorReportRepository"; +import { ProjectRepository } from "../repositories/ProjectRepository"; +import { UniqueBeneficiariesSettingsRepository } from "../repositories/UniqueBeneficiariesSettingsRepository"; + +export class GetProjectsByCountryUseCase { + constructor( + private projectRepository: ProjectRepository, + private beneficiariesSettingsRepository: UniqueBeneficiariesSettingsRepository, + private dataElementRepository: DataElementRepository, + private indicatorRepository: IndicatorReportRepository + ) {} + + async execute(options: GetCountryIndicatorsOptions): Promise { + const projects = await this.getProjectsByCountry(options.countryId); + const [settings, existingReports] = await Promise.all([ + this.getAllSettings(projects.map(project => project.id)), + this.indicatorRepository.getByCountry(options.countryId), + ]); + + const settingsInProjects = this.filterSettingsByProjects(settings, projects); + const indicatorsIds = this.getAllIndicatorsIds(settingsInProjects); + const dataElements = await this.dataElementRepository.getByIds(indicatorsIds); + + const settingsWithIndicatorsDetails = await this.buildSettingsWithIndicators( + settingsInProjects, + dataElements + ); + + const indicatorsReports = this.buildIndicatorReportFromProjects( + projects, + settingsWithIndicatorsDetails, + existingReports, + options.countryId, + dataElements + ); + return { indicatorsReports, settings: settingsWithIndicatorsDetails }; + } + + private buildIndicatorReportFromProjects( + projects: ProjectForList[], + settings: UniqueBeneficiariesSettings[], + existingReports: IndicatorReport[], + countryId: Id, + dataElements: DataElement[] + ): IndicatorReport[] { + const uniquePeriods = this.getUniquePeriodsFromSettings(settings); + const allYears = _(projects) + .map(project => { + return getYearsFromProject(project.openingDate, project.closedDate); + }) + .flatten() + .uniq() + .sort() + .value(); + + const { periodsKeys, periodsByYears } = IndicatorValidation.groupPeriodsAndYears( + allYears, + uniquePeriods + ); + + return periodsKeys.map((periodYearKey): IndicatorReport => { + const { period, year } = periodsByYears[periodYearKey]; + const existingData = existingReports.find( + report => + report.period.equalMonths(period.startDateMonth, period.endDateMonth) && + report.year === year && + report.countryId === countryId + ); + + return IndicatorReport.create({ + year, + countryId, + createdAt: existingData?.createdAt || "", + lastUpdatedAt: existingData?.lastUpdatedAt || "", + period, + projects: this.generateProjects( + projects, + settings, + period, + dataElements, + year, + existingData + ), + }); + }); + } + + private getSettingsProject( + settings: UniqueBeneficiariesSettings[], + projectId: Id + ): Maybe { + const currentSettings = settings.find(setting => setting.projectId === projectId); + + return !currentSettings?.indicatorsIds || currentSettings?.indicatorsIds.length === 0 + ? undefined + : currentSettings; + } + + private getUniquePeriodsFromSettings( + settings: UniqueBeneficiariesSettings[] + ): UniqueBeneficiariesPeriod[] { + const allPeriods = settings.flatMap(setting => setting.periods); + return UniqueBeneficiariesPeriod.uniquePeriodsByDates(allPeriods); + } + + private generateProjects( + projectsByPeriod: ProjectForList[], + settings: UniqueBeneficiariesSettings[], + period: UniqueBeneficiariesPeriod, + dataElements: DataElement[], + year: number, + existingRecord: Maybe + ): ProjectRows[] { + return _(projectsByPeriod) + .map(project => { + const existingProject = existingRecord?.projects.find( + item => item.id === project.id + ); + const settingsProject = this.getSettingsProject(settings, project.id); + if (!settingsProject) return undefined; + + const notIndicatorsAvailable = this.isProjectNotAvailable( + period, + settingsProject, + project, + year + ); + + const indicatorsCalculation = settingsProject?.indicatorsValidation + .find( + item => + period.equalMonths( + item.period.startDateMonth, + item.period.endDateMonth + ) && item.year === year + ) + ?.indicatorsCalculation.map((indicator): ProjectIndicatorRow => { + const existingIndicator = existingProject?.indicators.find( + item => item.indicatorId === indicator.id + ); + return { + periodNotAvailable: notIndicatorsAvailable, + include: existingIndicator?.include || false, + indicatorCode: indicator.code || "", + indicatorName: indicator.name || "", + indicatorId: indicator.id, + value: IndicatorCalculation.getTotal(indicator), + }; + }); + + const projectIndicators = + indicatorsCalculation && indicatorsCalculation.length > 0 + ? indicatorsCalculation + : settingsProject.indicatorsIds.map((indicatorId): ProjectIndicatorRow => { + const dataElementDetails = dataElements.find( + dataElement => dataElement.id === indicatorId + ); + const existingIndicator = existingProject?.indicators.find( + item => item.indicatorId === indicatorId + ); + return { + periodNotAvailable: notIndicatorsAvailable, + indicatorId, + indicatorCode: dataElementDetails?.code || "", + indicatorName: dataElementDetails?.name || "", + value: 0, + include: existingIndicator?.include || false, + }; + }); + + return { id: project.id, project: project, indicators: projectIndicators }; + }) + .compact() + .value(); + } + + private isProjectNotAvailable( + period: UniqueBeneficiariesPeriod, + settings: UniqueBeneficiariesSettings, + project: ProjectCountry, + year: number + ): boolean { + const periodExist = settings.periods.find(item => + period.equalMonths(item.startDateMonth, item.endDateMonth) + ); + + const projectIsInYear = checkProjectDateIsInYear( + project.openingDate, + project.closedDate, + year + ); + + return !periodExist || !projectIsInYear; + } + + private async buildSettingsWithIndicators( + settings: UniqueBeneficiariesSettings[], + dataElements: DataElement[] + ): Promise { + return settings.map(setting => { + return { + ...setting, + indicatorsValidation: setting.indicatorsValidation.map(indicatorValidation => { + return IndicatorValidation.build({ + ...indicatorValidation, + indicatorsCalculation: indicatorValidation.indicatorsCalculation.map( + indicatorCalculation => { + const dataElement = dataElements.find( + dataElement => dataElement.id === indicatorCalculation.id + ); + return IndicatorCalculation.build({ + ...indicatorCalculation, + code: dataElement?.code || "", + name: dataElement?.name || "", + }).get(); + } + ), + }).get(); + }), + }; + }); + } + + private getAllIndicatorsIds(settings: UniqueBeneficiariesSettings[]): Id[] { + return settings.flatMap(setting => setting.indicatorsIds); + } + + private filterSettingsByProjects( + settings: UniqueBeneficiariesSettings[], + projects: ProjectForList[] + ): UniqueBeneficiariesSettings[] { + const projectIds = projects.map(getId); + const settingsByProjects = settings.filter(setting => + projectIds.includes(setting.projectId) + ); + return settingsByProjects; + } + + private getProjectsByCountry(countryId: Id): Promise { + return this.projectRepository.getByCountries(countryId); + } + + private getAllSettings(projectsIds: Id[]): Promise { + return this.beneficiariesSettingsRepository.getAll({ projectsIds }); + } +} + +export type GetCountryIndicatorsOptions = { countryId: Id }; + +export type IndicatorsReportsResult = { + indicatorsReports: IndicatorReport[]; + settings: UniqueBeneficiariesSettings[]; +}; diff --git a/src/domain/usecases/GetUniqueBeneficiariesSettingsUseCase.ts b/src/domain/usecases/GetUniqueBeneficiariesSettingsUseCase.ts new file mode 100644 index 00000000..39d14efa --- /dev/null +++ b/src/domain/usecases/GetUniqueBeneficiariesSettingsUseCase.ts @@ -0,0 +1,11 @@ +import { Id } from "../entities/Ref"; +import { UniqueBeneficiariesSettings } from "../entities/UniqueBeneficiariesSettings"; +import { UniqueBeneficiariesSettingsRepository } from "../repositories/UniqueBeneficiariesSettingsRepository"; + +export class GetUniqueBeneficiariesSettingsUseCase { + constructor(private uniqueBeneficiariesRepository: UniqueBeneficiariesSettingsRepository) {} + + execute(id: Id): Promise { + return this.uniqueBeneficiariesRepository.get(id); + } +} diff --git a/src/domain/usecases/ImportDataElementsUseCase.ts b/src/domain/usecases/ImportDataElementsUseCase.ts index 15d8f9ee..d950e9a1 100644 --- a/src/domain/usecases/ImportDataElementsUseCase.ts +++ b/src/domain/usecases/ImportDataElementsUseCase.ts @@ -84,12 +84,14 @@ export class ImportDataElementsUseCase { const orgUnit = await this.getOrgUnitId(); console.info("Looking for data values in dataElements to be removed..."); const dataValues = await this.dataValueRepository.get({ + dataSetIds: undefined, includeDeleted: false, orgUnitIds: [orgUnit.id], children: true, endDate: "2050", startDate: "1950", dataElementsIds: dataElementsToRemove.map(dataElement => dataElement.id), + logDataElements: true, }); await this.exportDataValues(dataValues); diff --git a/src/domain/usecases/RemoveUniqueBeneficiariesPeriodUseCase.ts b/src/domain/usecases/RemoveUniqueBeneficiariesPeriodUseCase.ts new file mode 100644 index 00000000..4cd756dd --- /dev/null +++ b/src/domain/usecases/RemoveUniqueBeneficiariesPeriodUseCase.ts @@ -0,0 +1,23 @@ +import i18n from "../../locales"; +import { UniqueBeneficiariesPeriod } from "../entities/UniqueBeneficiariesPeriod"; +import { UniquePeriodRepository } from "../repositories/UniquePeriodRepository"; + +export class RemoveUniqueBeneficiariesPeriodUseCase { + constructor(private repository: UniquePeriodRepository) {} + + async execute(options: Options): Promise { + const periods = await this.repository.getByProject(options.projectId); + const isPeriodProtected = options.period.isProtected(); + if (isPeriodProtected) { + throw new Error(i18n.t("Cannot delete a protected period")); + } + return this.save(periods, options); + } + + private save(periods: UniqueBeneficiariesPeriod[], options: Options) { + const periodsToSave = periods.filter(period => period.id !== options.period.id); + return this.repository.save(options.projectId, periodsToSave); + } +} + +export type Options = { projectId: string; period: UniqueBeneficiariesPeriod }; diff --git a/src/domain/usecases/SaveIndicatorReportUseCase.ts b/src/domain/usecases/SaveIndicatorReportUseCase.ts new file mode 100644 index 00000000..c963394c --- /dev/null +++ b/src/domain/usecases/SaveIndicatorReportUseCase.ts @@ -0,0 +1,16 @@ +import { IndicatorReport } from "../entities/IndicatorReport"; +import { Id } from "../entities/Ref"; +import { IndicatorReportRepository } from "../repositories/IndicatorReportRepository"; + +export class SaveIndicatorReportUseCase { + constructor(private indicatorReportRepository: IndicatorReportRepository) {} + + execute(options: SaveIndicatorOptions): Promise { + const onlyReportsWithProjects = options.reports.filter( + report => report.projects.length > 0 + ); + return this.indicatorReportRepository.save(onlyReportsWithProjects, options.countryId); + } +} + +type SaveIndicatorOptions = { countryId: Id; reports: IndicatorReport[] }; diff --git a/src/domain/usecases/SaveIndicatorsValidationUseCase.ts b/src/domain/usecases/SaveIndicatorsValidationUseCase.ts new file mode 100644 index 00000000..0a4c76f9 --- /dev/null +++ b/src/domain/usecases/SaveIndicatorsValidationUseCase.ts @@ -0,0 +1,69 @@ +import i18n from "../../locales"; +import { IndicatorValidation, IndicatorValidationAttrs } from "../entities/IndicatorValidation"; +import { Id } from "../entities/Ref"; +import { UniqueBeneficiariesSettings } from "../entities/UniqueBeneficiariesSettings"; +import { UniqueBeneficiariesSettingsRepository } from "../repositories/UniqueBeneficiariesSettingsRepository"; + +export class SaveIndicatorsValidationUseCase { + constructor(private settingsRepository: UniqueBeneficiariesSettingsRepository) {} + + async execute(options: SaveIndicatorsOptions): Promise { + const { indicatorsValidations, projectId } = options; + const settings = await this.settingsRepository.get(projectId); + + const rowWithError = this.getIndexRowWithError(indicatorsValidations); + + if (rowWithError !== -1) { + const row = indicatorsValidations[rowWithError]; + throw new Error( + i18n.t("Cannot save indicators without comments for period: {{period}}", { + period: row.period.name, + nsSeparator: false, + }) + ); + } + + const indicatorsToSave = this.buildIndicatorsToSave(indicatorsValidations, settings); + + return this.saveIndicators(settings, indicatorsToSave); + } + + private getIndexRowWithError(indicatorsValidations: IndicatorValidation[]): number { + return indicatorsValidations.findIndex(item => + IndicatorValidation.validateCommentIndicators(item.indicatorsCalculation) + ); + } + + private saveIndicators( + settings: UniqueBeneficiariesSettings, + indicatorsToSave: IndicatorValidation[] + ): Promise { + const settingsToSave: UniqueBeneficiariesSettings = { + ...settings, + indicatorsValidation: indicatorsToSave, + }; + + return this.settingsRepository.save(settingsToSave); + } + + private buildIndicatorsToSave( + indicatorsValidations: IndicatorValidation[], + settings: UniqueBeneficiariesSettings + ): IndicatorValidation[] { + return indicatorsValidations.map(indicator => { + const indicatorExist = settings.indicatorsValidation.find(item => + item.checkPeriodAndYear(indicator.period.id, indicator.year) + ); + + const currentDate = new Date().toISOString(); + const indicatorAttributes: IndicatorValidationAttrs = { + ...indicator, + lastUpdatedAt: indicatorExist ? currentDate : undefined, + createdAt: indicatorExist ? indicatorExist.createdAt : currentDate, + }; + return IndicatorValidation.build(indicatorAttributes).get(); + }); + } +} + +type SaveIndicatorsOptions = { projectId: Id; indicatorsValidations: IndicatorValidation[] }; diff --git a/src/domain/usecases/SaveUniquePeriodsUseCase.ts b/src/domain/usecases/SaveUniquePeriodsUseCase.ts new file mode 100644 index 00000000..e3368ac7 --- /dev/null +++ b/src/domain/usecases/SaveUniquePeriodsUseCase.ts @@ -0,0 +1,74 @@ +import i18n from "../../locales"; +import { UniqueBeneficiariesPeriod } from "../entities/UniqueBeneficiariesPeriod"; +import { UniquePeriodRepository } from "../repositories/UniquePeriodRepository"; + +export class SaveUniquePeriodsUseCase { + constructor(private repository: UniquePeriodRepository) {} + + async execute(options: SaveSettingsOptions): Promise { + const periods = await this.repository.getByProject(options.projectId); + const periodExist = periods.some(period => period.id === options.period.id); + const isPeriodProtected = options.period.isProtected(); + const { isValid, errorMessage } = UniqueBeneficiariesPeriod.validate(options.period); + + if (this.isAnnualOrSemiAnnual(options.period)) { + throw new Error(i18n.t("Period is equal to the predefined annual/semi-annual period")); + } else if (!isValid) { + throw new Error(errorMessage); + } else if (isPeriodProtected) { + throw new Error(i18n.t("Cannot save a protected period")); + } else if (this.validateMonths(options.period, periods)) { + throw new Error(i18n.t("Already exist a period with the same months")); + } else { + return this.saveSettings(periods, periodExist, options); + } + } + + private validateMonths( + period: UniqueBeneficiariesPeriod, + existingPeriods: UniqueBeneficiariesPeriod[] + ): boolean { + return existingPeriods.some(existingPeriod => + existingPeriod.equalMonths(period.startDateMonth, period.endDateMonth) + ); + } + + private isAnnualOrSemiAnnual(period: UniqueBeneficiariesPeriod): boolean { + const defaultPeriods = UniqueBeneficiariesPeriod.defaultPeriods(); + return defaultPeriods.some(defaultPeriod => + defaultPeriod.equalMonths(period.startDateMonth, period.endDateMonth) + ); + } + + private saveSettings( + periods: UniqueBeneficiariesPeriod[], + periodExist: boolean, + options: SaveSettingsOptions + ) { + const periodsToSave = this.buildPeriodsToSave(periods, options.period, periodExist); + return this.repository.save(options.projectId, periodsToSave); + } + + private buildPeriodsToSave( + existingPeriods: UniqueBeneficiariesPeriod[], + currentPeriod: UniqueBeneficiariesPeriod, + periodExist: boolean + ): UniqueBeneficiariesPeriod[] { + const periodsToSave = periodExist + ? existingPeriods.map(period => { + return period.id === currentPeriod.id ? currentPeriod : period; + }) + : [...existingPeriods, currentPeriod]; + + if (!this.checkDuplicatesInPeriods(periodsToSave)) { + throw new Error(i18n.t("Cannot save duplicate periods")); + } + return periodsToSave; + } + + private checkDuplicatesInPeriods(periods: UniqueBeneficiariesPeriod[]): boolean { + return periods.length === new Set(periods.map(period => period.name)).size; + } +} + +export type SaveSettingsOptions = { projectId: string; period: UniqueBeneficiariesPeriod }; diff --git a/src/hooks/UniqueBeneficiaries.ts b/src/hooks/UniqueBeneficiaries.ts new file mode 100644 index 00000000..8731707c --- /dev/null +++ b/src/hooks/UniqueBeneficiaries.ts @@ -0,0 +1,20 @@ +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import React from "react"; +import { useAppContext } from "../contexts/api-context"; +import { UniqueBeneficiariesSettings } from "../domain/entities/UniqueBeneficiariesSettings"; +import { Id } from "../models/ProjectDocument"; + +export function useGetUniqueBeneficiaries(props: { id: Id; refresh: number }) { + const { id, refresh } = props; + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + const [settings, setSettings] = React.useState(); + + React.useEffect(() => { + compositionRoot.uniqueBeneficiaries.getSettings.execute(id).then(setSettings, err => { + snackbar.error(err.message); + }); + }, [compositionRoot.uniqueBeneficiaries.getSettings, snackbar, id, refresh]); + + return { settings, setSettings }; +} diff --git a/src/index.ts b/src/index.ts index a958bcb9..3002abd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,7 +59,13 @@ async function main() { // eslint-disable-next-line react/no-children-prop React.createElement( Provider, - { config, children: null }, + { + config, + children: null, + plugin: false, + parentAlertsAdd: undefined, + showAlertsInPlugin: true, + }, React.createElement(App, { d2, api, dhis2Url: baseUrl }) ), document.getElementById("root") diff --git a/src/models/MerReport.ts b/src/models/MerReport.ts index 0b41f41e..49932c5e 100644 --- a/src/models/MerReport.ts +++ b/src/models/MerReport.ts @@ -92,6 +92,7 @@ interface OrgUnit { } export interface ProjectInfo { + uniqueBeneficiaries: { indicatorsIds: Id[] }; merDataElementIds: string[]; documents: string[]; } diff --git a/src/models/Project.ts b/src/models/Project.ts index 4f2e78a8..4d885074 100644 --- a/src/models/Project.ts +++ b/src/models/Project.ts @@ -28,6 +28,8 @@ import { getIds } from "../utils/dhis2"; import { ProjectInfo } from "./ProjectInfo"; import { isTest } from "../utils/testing"; import { MAX_SIZE_PROJECT_IN_MB, ProjectDocument } from "./ProjectDocument"; +import { promiseMap } from "../migrations/utils"; +import { ISODateTimeString } from "../domain/entities/Ref"; /* Project model. @@ -102,6 +104,7 @@ export interface ProjectData { parentOrgUnit: OrganisationUnit | undefined; dataElementsSelection: DataElementsSet; dataElementsMER: DataElementsSet; + uniqueIndicators: DataElementsSet; disaggregation: Disaggregation; dataSets: { actual: DataSet; target: DataSet } | undefined; dashboard: Partial; @@ -185,6 +188,7 @@ const validationKeys = [ "dataElementsSelection" as const, "dataElementsMER" as const, "endDateAfterStartDate" as const, + "uniqueIndicators" as const, ]; export type ProjectField = keyof ProjectData; @@ -237,6 +241,7 @@ class Project { documents: i18n.t("Documents"), isDartApplicable: i18n.t("DART"), partner: i18n.t("Partner"), + uniqueIndicators: i18n.t("Unique Indicators"), }; static getFieldName(field: ProjectField): string { @@ -400,9 +405,17 @@ class Project { } public getSectorsInfo(): SectorsInfo { - const { dataElementsSelection, dataElementsMER, sectors } = this; + const { + dataElementsSelection, + dataElementsMER, + sectors, + uniqueIndicators: uniqueBeneficiaries, + } = this; const dataElementsBySectorMapping = new ProjectDb(this).getDataElementsBySectorMapping(); const selectedMER = new Set(dataElementsMER.get({ onlySelected: true }).map(de => de.id)); + const selectedBeneficiaries = new Set( + uniqueBeneficiaries.get({ onlySelected: true }).map(de => de.id) + ); return sectors.map(sector => { const getOptions = { onlySelected: true, includePaired: true, sectorId: sector.id }; @@ -410,6 +423,7 @@ class Project { const dataElementsInfo = dataElements.map(dataElement => ({ dataElement, isMER: selectedMER.has(dataElement.id), + isUniqueBeneficiary: selectedBeneficiaries.has(dataElement.id), isCovid19: this.disaggregation.isCovid19(dataElement.id), usedInDataSetSection: dataElementsBySectorMapping[dataElement.id] === sector.id, })); @@ -461,6 +475,12 @@ class Project { groupPaired: false, superSet: dataElementsSelection, }); + + const uniqueIndicators = DataElementsSet.build(config, { + groupPaired: false, + superSet: dataElementsSelection, + }); + const projectData = { ...defaultProjectData, id: generateUid(), @@ -471,6 +491,7 @@ class Project { initialData: undefined, isDartApplicable: false, partner: false, + uniqueIndicators: uniqueIndicators, }; return new Project(api, config, projectData); } @@ -538,7 +559,7 @@ class Project { } updateDataElementsSelection(sectorId: string, dataElementIds: string[]) { - const { dataElementsSelection, dataElementsMER, sectors } = this.data; + const { dataElementsSelection, dataElementsMER, sectors, uniqueIndicators } = this.data; const res = dataElementsSelection.updateSelectedWithRelations({ sectorId, dataElementIds }); const { dataElements: dataElementsUpdate, selectionInfo } = res; @@ -557,6 +578,7 @@ class Project { sectors: newSectors, dataElementsSelection: dataElementsUpdate, dataElementsMER: dataElementsMER.updateSuperSet(dataElementsUpdate), + uniqueIndicators: uniqueIndicators.updateSuperSet(dataElementsUpdate), }); return { selectionInfo, project: newProject }; } @@ -568,6 +590,15 @@ class Project { return { selectionInfo: {}, project: newProject }; } + updateUniqueBeneficiariesSelection(sectorId: string, dataElementIds: string[]) { + const { uniqueIndicators: uniqueBeneficiaries } = this.data; + const newDataElementsSelected = uniqueBeneficiaries.updateSelected({ + [sectorId]: dataElementIds, + }); + const newProject = this.setObj({ uniqueIndicators: newDataElementsSelected }); + return { selectionInfo: {}, project: newProject }; + } + public get uid() { return this.id; } @@ -666,6 +697,33 @@ class Project { .compact() .join("-"); } + + static async clone(api: D2Api, config: Config, id: Id): Promise { + const existingProject = await Project.get(api, config, id); + const clonedDocuments = await promiseMap(existingProject.documents, async document => { + if (!document.id) throw new Error("Document id is missing"); + const fileResourceBlob = await api.files.get(document.id).getData(); + return ProjectDocument.create({ + ...document, + id: "", + url: undefined, + sharing: undefined, + blob: fileResourceBlob, + }); + }); + + const projectToClone = Project.create(api, config).set("initialData", { + ...existingProject.data, + documents: clonedDocuments, + }); + return projectToClone.setObj({ + ...existingProject.data, + id: generateUid(), + orgUnit: undefined, + dataSets: undefined, + created: undefined, + }); + } } interface Project extends ProjectData {} @@ -750,9 +808,20 @@ export function getPeriodsData(dataSet: DataSet) { return { periodIds, currentPeriodId }; } +export function checkProjectDateIsInYear( + startDate: ISODateTimeString, + endDate: ISODateTimeString, + year: number +): boolean { + const start = new Date(startDate); + const end = new Date(endDate); + return year >= start.getFullYear() && year <= end.getFullYear(); +} + export type DataElementInfo = { dataElement: DataElement; isMER: boolean; + isUniqueBeneficiary: boolean; isCovid19: boolean; usedInDataSetSection: boolean; }; @@ -768,3 +837,5 @@ export type ProjectBasic = Pick< >; export default Project; + +export type ProjectAction = "create" | "edit" | "clone"; diff --git a/src/models/ProjectDb.ts b/src/models/ProjectDb.ts index 50256bec..8f19f5e3 100644 --- a/src/models/ProjectDb.ts +++ b/src/models/ProjectDb.ts @@ -10,7 +10,7 @@ import { DataValueSetsPostRequest, DataValueSetsPostResponse, } from "../types/d2-api"; -import { D2DataSet, D2OrganisationUnit, D2ApiResponse } from "../types/d2-api"; +import { D2DataSet, D2OrganisationUnit } from "../types/d2-api"; import { PartialModel, Ref, PartialPersistedModel, MetadataResponse } from "../types/d2-api"; import Project, { getOrgUnitDatesFromProject, @@ -371,7 +371,7 @@ export default class ProjectDb { await this.saveMERData( project.id, projectDocumentsSaved.filter(document => !document.markAsDeleted) - ).getData(); + ); } async saveMetadata() { @@ -503,15 +503,26 @@ export default class ProjectDb { } } - saveMERData(orgUnitId: Id, projectDocuments: ProjectDocument[]): D2ApiResponse { + async saveMERData(orgUnitId: Id, projectDocuments: ProjectDocument[]): Promise { const dataStore = getDataStore(this.project.api); + const existingData = await dataStore + .get(getProjectStorageKey({ id: orgUnitId })) + .getData(); + const dataElementsForMER = this.project.dataElementsMER.get({ onlySelected: true }); + const selectedUniqueBeneficiariesIds = this.project.uniqueIndicators + .get({ onlySelected: true }) + .map(dataElement => dataElement.id); const ids = _.sortBy(_.uniq(dataElementsForMER.map(de => de.id))); const value: ProjectInfo = { merDataElementIds: ids, + uniqueBeneficiaries: { + ...(existingData?.uniqueBeneficiaries || {}), + indicatorsIds: selectedUniqueBeneficiariesIds, + }, documents: projectDocuments.map(document => document.id), }; - return dataStore.save(getProjectStorageKey({ id: orgUnitId }), value); + await dataStore.save(getProjectStorageKey({ id: orgUnitId }), value).getData(); } /* @@ -728,9 +739,12 @@ export default class ProjectDb { const sector = _(sectorsByCode).get(sectorCode); const selectedIds = section.dataElements.map(de => de.id); const selectedMERIds = _.intersection(selectedIds, projectInfo.merDataElementIds); - const value = { selectedIds, selectedMERIds }; - type Value = { selectedIds: Id[]; selectedMERIds: Id[] }; - return sector ? ([sector.id, value] as [string, Value]) : null; + const uniqueIndicatorsIds = _.intersection( + selectedIds, + projectInfo.uniqueBeneficiaries.indicatorsIds + ); + const value = { selectedIds, selectedMERIds, uniqueIndicatorsIds }; + return sector ? ([sector.id, value] as [string, typeof value]) : null; }) .compact() .fromPairs() @@ -746,6 +760,11 @@ export default class ProjectDb { superSet: dataElementsSelection, }).updateSelected(_.mapValues(dataElementsBySectorId, value => value.selectedMERIds)); + const uniqueIndicators = DataElementsSet.build(config, { + groupPaired: false, + superSet: dataElementsSelection, + }).updateSelected(_.mapValues(dataElementsBySectorId, value => value.uniqueIndicatorsIds)); + const { dataSetElements } = projectDataSets.actual; const disaggregation = Disaggregation.buildFromDataSetElements(config, dataSetElements); const codeInfo = ProjectDb.getCodeInfo(code); @@ -788,6 +807,7 @@ export default class ProjectDb { }), isDartApplicable: isInDartApplicableGroup, partner: partnerGroup, + uniqueIndicators, }; const project = new Project(api, config, { ...projectData, initialData: projectData }); return project; @@ -880,7 +900,13 @@ async function getDataElementIdsForMer(api: D2Api, id: string) { .get(getProjectStorageKey({ id })) .getData(); if (!value) console.error("Cannot get MER selections"); - return { documents: value?.documents || [], merDataElementIds: value?.merDataElementIds || [] }; + return { + uniqueBeneficiaries: { + indicatorsIds: value?.uniqueBeneficiaries?.indicatorsIds || [], + }, + documents: value?.documents || [], + merDataElementIds: value?.merDataElementIds || [], + }; } export function getSectorCodeFromSectionCode(code: string | undefined) { diff --git a/src/models/ProjectInfo.ts b/src/models/ProjectInfo.ts index 69c2aa6f..4b7ee29a 100644 --- a/src/models/ProjectInfo.ts +++ b/src/models/ProjectInfo.ts @@ -209,13 +209,13 @@ export class ProjectInfo { } private getDataElementInfoAsString(info: DataElementInfo) { - const { dataElement, isMER, isCovid19 } = info; + const { dataElement, isMER, isUniqueBeneficiary, isCovid19 } = info; const hiddenMsg = i18n.t("Hidden in data entry as it is selected in multiple sectors!"); - return [ `${dataElement.name} - ${dataElement.code}`, isCovid19 ? ` [${i18n.t("COVID-19")}]` : "", isMER ? ` [${i18n.t("MER")}]` : "", + isUniqueBeneficiary ? ` [${i18n.t("Unique Beneficiary")}]` : "", info.usedInDataSetSection ? "" : ` - ${hiddenMsg}`, ].join(""); } diff --git a/src/models/ProjectNotification.ts b/src/models/ProjectNotification.ts index b8ccf6a4..b3916b9a 100644 --- a/src/models/ProjectNotification.ts +++ b/src/models/ProjectNotification.ts @@ -1,6 +1,6 @@ import _ from "lodash"; -import Project, { DataSetType } from "./Project"; +import Project, { DataSetType, ProjectAction } from "./Project"; import i18n from "../locales"; import User from "./user"; import { generateUrl } from "../router"; @@ -11,7 +11,7 @@ import moment from "moment"; import { appConfig } from "../app-config"; type Email = string; -type Action = "create" | "update"; +type Action = ProjectAction; export class ProjectNotification { constructor( @@ -195,9 +195,22 @@ The reason provided by the user was: return this.sendMessage({ recipients, subject, text: text.trim() }); } + static buildBaseMessage(action: Action): string { + switch (action) { + case "create": + return i18n.t("Project created"); + case "edit": + return i18n.t("Project updated"); + case "clone": + return i18n.t("Project created"); + default: + throw new Error(`Unknown action: ${action}`); + } + } + private async notifySave(recipients: Email[], action: Action) { const { project, currentUser } = this; - const baseMsg = action === "create" ? i18n.t("Project created") : i18n.t("Project updated"); + const baseMsg = ProjectNotification.buildBaseMessage(action); const subject = `${baseMsg}: ${this.project.name}`; const body = [ diff --git a/src/models/__tests__/ProjectDb.spec.ts b/src/models/__tests__/ProjectDb.spec.ts index c2def890..24addf68 100644 --- a/src/models/__tests__/ProjectDb.spec.ts +++ b/src/models/__tests__/ProjectDb.spec.ts @@ -154,6 +154,11 @@ describe("ProjectDb", () => { dataStoreUpdateResponse ); + mock.onGet("/dataStore/data-management-app/project-WGC0DJ0YSis").replyOnce( + 200, + expectedDataStoreMer + ); + mock.onPut( "/dataStore/data-management-app/project-WGC0DJ0YSis", expectedDataStoreMer @@ -223,6 +228,7 @@ const orgUnitsMetadata = { const expectedDataStoreMer = { merDataElementIds: ["yMqK9DKbA3X"], documents: [], + uniqueBeneficiaries: { periods: [], indicatorsIds: [] }, }; const expectedOrgUnitPut = { diff --git a/src/models/dataElementsSet.ts b/src/models/dataElementsSet.ts index b3c5e0fe..772f1cc8 100644 --- a/src/models/dataElementsSet.ts +++ b/src/models/dataElementsSet.ts @@ -300,7 +300,6 @@ export default class DataElementsSet { const { indicatorType, peopleOrBenefit, external } = options; const dataElementsBySector = this.data.dataElementsBySector; const sectorsIds = sectorId ? [sectorId] : _.keys(dataElementsBySector); - return _.flatMap(sectorsIds, sectorId => { const dataElements1: DataElement[] = _(dataElementsBySector).get(sectorId, []); if (_.isEqual(options, {})) return dataElements1; diff --git a/src/models/user.ts b/src/models/user.ts index 05a3b222..3b223eb1 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -14,10 +14,13 @@ export type Action = | "countryDashboard" | "downloadData" | "edit" + | "clone" | "dataApproval" | "reopen" | "delete" - | "attachFiles"; + | "attachFiles" + | "periods" + | "projectIndicators"; const actionsByRole: Record = { admin: [ @@ -29,11 +32,14 @@ const actionsByRole: Record = { "countryDashboard", "downloadData", "edit", + "clone", "delete", "dataApproval", "accessMER", "reopen", "attachFiles", + "periods", + "projectIndicators", ], dataReviewer: [ "create", @@ -48,14 +54,17 @@ const actionsByRole: Record = { "accessMER", "reopen", "attachFiles", + "projectIndicators", + "clone", ], - dataViewer: ["dashboard", "awardNumberDashboard", "downloadData"], + dataViewer: ["dashboard", "awardNumberDashboard", "downloadData", "projectIndicators"], merApprover: [ "dashboard", "countryDashboard", "awardNumberDashboard", "downloadData", "accessMER", + "projectIndicators", ], dataEntry: [ "targetValues", @@ -64,6 +73,7 @@ const actionsByRole: Record = { "awardNumberDashboard", "downloadData", "attachFiles", + "projectIndicators", ], }; diff --git a/src/pages/country-indicator-report/CountryIndicatorReport.tsx b/src/pages/country-indicator-report/CountryIndicatorReport.tsx new file mode 100644 index 00000000..3a535fb4 --- /dev/null +++ b/src/pages/country-indicator-report/CountryIndicatorReport.tsx @@ -0,0 +1,301 @@ +import _ from "lodash"; +import React from "react"; +import { + ConfirmationDialog, + Dropdown, + useLoading, + useSnackbar, + DropdownItem, +} from "@eyeseetea/d2-ui-components"; +import { Button, Grid, Typography } from "@material-ui/core"; + +import UserOrgUnits, { OrganisationUnit } from "../../components/org-units/UserOrgUnits"; +import PageHeader from "../../components/page-header/PageHeader"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import i18n from "../../locales"; +import { useGoTo } from "../../router"; +import { Maybe } from "../../types/utils"; +import { GroupedRows, IndicatorReportTable } from "./IndicatorReportTable"; +import { useAppContext } from "../../contexts/api-context"; +import { IndicatorReport } from "../../domain/entities/IndicatorReport"; +import { Id } from "../../domain/entities/Ref"; +import { buildSpreadSheet } from "./excel-report"; +import { downloadFile } from "../../utils/download"; +import { useConfirmChanges } from "../report/MerReport"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import { getYearsFromProject } from "../project-indicators-validation/ProjectIndicatorsValidation"; + +export const CountryIndicatorReport = React.memo(() => { + const goTo = useGoTo(); + const [orgUnit, setOrgUnit] = React.useState(); + const [year, setYear] = React.useState(); + const [selectedPeriod, setSelectedPeriod] = React.useState(); + const { confirmIfUnsavedChanges, proceedWarning, runProceedAction, wasReportModifiedSet } = + useConfirmChanges(); + const { indicatorsReports, settings, setIndicatorsReports } = useGetIndicatorsReport({ + countryId: orgUnit?.id, + wasReportModifiedSet, + }); + + const saveIndicatorReport = useSaveIndicatorReport({ + countryId: orgUnit?.id, + indicatorsReports, + wasReportModifiedSet, + }); + + const updateOrgUnit = (orgUnit: OrganisationUnit) => { + confirmIfUnsavedChanges(() => { + setOrgUnit(orgUnit); + setSelectedPeriod(undefined); + }); + }; + + const updatePeriod = (periodId: Maybe) => { + const periods = getAllPeriods(indicatorsReports); + const period = periods.find(period => period.id === periodId); + if (period) { + setSelectedPeriod(period); + } + }; + + const updateYear = (year: Maybe) => { + if (year) setYear(Number(year)); + }; + + const updateReport = React.useCallback( + (value: boolean, row: GroupedRows) => { + if (!selectedPeriod || !year) return; + + const updatedIndicators = indicatorsReports.map(indicatorReport => { + if (!indicatorReport.checkPeriodAndYear(selectedPeriod.id, year)) + return indicatorReport; + + return indicatorReport.updateProjectIndicators(row.project.id, row.id, value); + }); + setIndicatorsReports(updatedIndicators); + wasReportModifiedSet(true); + }, + [indicatorsReports, selectedPeriod, setIndicatorsReports, wasReportModifiedSet, year] + ); + + const indicatorReport = + year && selectedPeriod + ? indicatorsReports.find(report => report.checkPeriodAndYear(selectedPeriod.id, year)) + : undefined; + + const downloadReport = () => { + if (indicatorReport && orgUnit && selectedPeriod && year) { + buildSpreadSheet({ + indicatorReport, + countryName: orgUnit.displayName, + period: selectedPeriod, + settings, + year, + }).then(downloadFile); + } + }; + + const changePage = React.useCallback(() => { + confirmIfUnsavedChanges(() => { + goTo("projects"); + }); + }, [confirmIfUnsavedChanges, goTo]); + + const reportHasProjects = indicatorReport && indicatorReport.projects.length > 0; + const titlePage = i18n.t("Country Project & Indicators"); + + const years = mapYearsToItems(indicatorsReports); + + return ( +
    + + + + + + + + + + + + + {reportHasProjects && selectedPeriod && year && ( + <> + + + + + + + + + + + + + + + )} + {selectedPeriod && year && !reportHasProjects && ( + + {i18n.t("No projects found for selected period: {{period}}", { + nsSeparator: false, + period: selectedPeriod ? selectedPeriod.name : "", + })} + + )} + + + {proceedWarning.type === "visible" && ( + runProceedAction(proceedWarning.action)} + onCancel={() => runProceedAction(() => {})} + title={titlePage} + description={i18n.t( + "Any changes will be lost. Are you sure you want to proceed?" + )} + saveText={i18n.t("Yes")} + cancelText={i18n.t("No")} + /> + )} +
    + ); +}); + +CountryIndicatorReport.displayName = "CountryIndicatorReport"; + +function getUniqueYearsFromProjects(indicatorsReports: IndicatorReport[]): number[] { + return _(indicatorsReports) + .flatMap(report => + report.projects.map(project => { + return getYearsFromProject(project.project.openingDate, project.project.closedDate); + }) + ) + .flatten() + .uniq() + .value(); +} + +function mapYearsToItems(indicatorsReports: IndicatorReport[]): DropdownItem[] { + const years = getUniqueYearsFromProjects(indicatorsReports); + return _(years) + .map(year => ({ text: year.toString(), value: year.toString() })) + .orderBy(item => item.text, "desc") + .value(); +} + +function getAllPeriods(indicatorsReports: IndicatorReport[]): UniqueBeneficiariesPeriod[] { + return _(indicatorsReports) + .flatMap(setting => setting.period) + .uniqBy(period => period.name) + .value(); +} + +function mapItemsToDropdown(periods: UniqueBeneficiariesPeriod[]) { + return periods.map(period => { + return { text: period.name, value: period.id }; + }); +} + +export function useSaveIndicatorReport(props: UseSaveCountryReportProps) { + const { compositionRoot } = useAppContext(); + const { countryId, indicatorsReports: reports, wasReportModifiedSet } = props; + const snackbar = useSnackbar(); + const loading = useLoading(); + + const saveIndicatorReport = React.useCallback(() => { + if (!countryId) return; + loading.show(true, i18n.t("Saving...")); + compositionRoot.indicators.saveReports + .execute({ reports, countryId }) + .then(() => { + snackbar.success("Report saved successfully"); + }) + .catch(err => { + snackbar.error(err.message); + }) + .finally(() => { + wasReportModifiedSet(false); + loading.hide(); + }); + }, [compositionRoot, loading, snackbar, countryId, reports, wasReportModifiedSet]); + + return saveIndicatorReport; +} + +export function useGetIndicatorsReport(props: { + countryId: Maybe; + wasReportModifiedSet: React.Dispatch>; +}) { + const { countryId, wasReportModifiedSet } = props; + const { compositionRoot } = useAppContext(); + const snackbar = useSnackbar(); + const loading = useLoading(); + + const [indicatorsReports, setIndicatorsReports] = React.useState([]); + const [settings, setSettings] = React.useState([]); + + React.useEffect(() => { + if (!countryId) return; + loading.show(true, i18n.t("Loading projects and indicators...")); + compositionRoot.projects.getByCountry + .execute({ countryId }) + .then(result => { + setIndicatorsReports(result.indicatorsReports); + setSettings(result.settings); + }) + .catch(error => snackbar.error(error.message)) + .finally(() => { + loading.hide(); + wasReportModifiedSet(false); + }); + }, [compositionRoot, countryId, loading, snackbar, wasReportModifiedSet]); + + return { indicatorsReports, settings, setIndicatorsReports }; +} + +type UseSaveCountryReportProps = { + countryId: Maybe; + indicatorsReports: IndicatorReport[]; + wasReportModifiedSet: React.Dispatch>; +}; diff --git a/src/pages/country-indicator-report/IndicatorReportTable.tsx b/src/pages/country-indicator-report/IndicatorReportTable.tsx new file mode 100644 index 00000000..1009e7cc --- /dev/null +++ b/src/pages/country-indicator-report/IndicatorReportTable.tsx @@ -0,0 +1,214 @@ +import _ from "lodash"; +import React from "react"; +import { + Checkbox, + Paper, + Table, + TableCell, + TableFooter, + TableHead, + TableRow, + Typography, +} from "@material-ui/core"; +import { IndicatorReportAttrs, ProjectCountry } from "../../domain/entities/IndicatorReport"; +import { Id } from "../../domain/entities/Ref"; +import i18n from "../../locales"; +import { Grouper, RowComponent } from "../report/rich-rows-utils"; +import TableBodyGrouped from "../report/TableBodyGrouped"; +import { buildMonthYearFormatDate } from "../../utils/date"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import { checkProjectDateIsInYear } from "../../models/Project"; + +type IndicatorReportTableProps = { + period: UniqueBeneficiariesPeriod; + settings: UniqueBeneficiariesSettings[]; + report: IndicatorReportAttrs; + onRowChange: (value: boolean, row: GroupedRows) => void; + year: number; +}; + +export const IndicatorReportTable = React.memo((props: IndicatorReportTableProps) => { + const { onRowChange, period, report, settings, year } = props; + const groupers: Grouper[] = React.useMemo(() => { + return [ + { + name: "project", + getId: row => row.project.id, + component: function ProjectCells(props) { + return ( + + ); + }, + }, + { + name: "indicator", + getId: row => [row.project.id, row.id].join("-"), + component: function DataElementCellsForIndicator(props) { + return ; + }, + }, + { + name: "total", + getId: row => [row.project.id, "total"].join("-"), + component: TotalCell, + }, + ]; + }, [onRowChange, period, settings]); + + const indicatorsRows = generateRows(report, year); + + return ( + + + + + {i18n.t("Project")} + {i18n.t("Selected Activity Indicators")} + {i18n.t("Unique Beneficiaries")} + {i18n.t("Include?")} + {i18n.t("Total Unique Served")} + + + + + + + + {i18n.t("Country Unique Beneficiaries")} + + + + {_(indicatorsRows).sumBy(row => (row.include ? row.value : 0))} + + + + +
    +
    + ); +}); + +IndicatorReportTable.displayName = "IndicatorReportTable"; + +const ProjectCell = (props: ProjectCellProps) => { + const { period, rowSpan, row, settings } = props; + + const currentPeriod = getCurrentPeriodForProject(settings, row.project.id, period); + const periodName = `Period: ${currentPeriod?.name || getNotAvailableText()}`; + + return ( + + {row.project.name}
    ({buildMonthYearFormatDate(row.project.openingDate)} -{" "} + {buildMonthYearFormatDate(row.project.closedDate)}) +
    + {row.periodNotAvailable ? getNotAvailableText() : periodName} +
    + ); +}; + +const TotalCell: RowComponent = props => { + return ( + + {props.row.periodNotAvailable ? getNotAvailableText() : props.row.total} + + ); +}; + +const IndicatorCell = (props: IndicatorCellProps) => { + const notAvailableText = getNotAvailableText(); + const isPeriodNotAvailable = props.row.periodNotAvailable; + + return ( + <> + + {props.row.name} ({props.row.code}) + + {isPeriodNotAvailable ? notAvailableText : props.row.value} + + {isPeriodNotAvailable ? ( + notAvailableText + ) : ( + props.onRowChange(event.target.checked, props.row)} + /> + )} + + + ); +}; + +IndicatorCell.displayName = "IndicatorCell"; +ProjectCell.displayName = "ProjectCell"; + +export type GroupedRows = { + id: Id; + code: string; + name: string; + value: number; + include: boolean; + total: number; + project: ProjectCountry; + periodNotAvailable: boolean; +}; + +type IndicatorCellProps = { + row: GroupedRows; + onRowChange: (value: boolean, row: GroupedRows) => void; +}; + +type ProjectCellProps = { + period: UniqueBeneficiariesPeriod; + row: GroupedRows; + rowSpan: number | undefined; + settings: UniqueBeneficiariesSettings[]; +}; + +function generateRows(report: IndicatorReportAttrs, year: number): GroupedRows[] { + return report.projects.flatMap(project => { + const sumIndicators = _(project.indicators) + .filter(indicator => indicator.include) + .sumBy(indicator => indicator.value || 0); + + if ( + !checkProjectDateIsInYear(project.project.openingDate, project.project.closedDate, year) + ) + return []; + + const allIndicators = project.indicators.map((indicator): GroupedRows => { + return { + id: indicator.indicatorId, + code: indicator.indicatorCode || "", + name: indicator.indicatorName, + value: indicator.value || 0, + include: indicator.include, + total: sumIndicators, + project: project.project, + periodNotAvailable: indicator.periodNotAvailable, + }; + }); + return allIndicators; + }); +} + +export function getCurrentPeriodForProject( + settings: UniqueBeneficiariesSettings[], + projectId: Id, + period: UniqueBeneficiariesPeriod +) { + const projectSettings = settings.find(setting => setting.projectId === projectId); + const currentPeriod = projectSettings?.periods.find(projectPeriod => + projectPeriod.equalMonths(period.startDateMonth, period.endDateMonth) + ); + return currentPeriod; +} + +function getNotAvailableText() { + return i18n.t("N/A"); +} diff --git a/src/pages/country-indicator-report/excel-report.ts b/src/pages/country-indicator-report/excel-report.ts new file mode 100644 index 00000000..9da3f24d --- /dev/null +++ b/src/pages/country-indicator-report/excel-report.ts @@ -0,0 +1,105 @@ +import ExcelJS from "exceljs"; +import _ from "lodash"; +import { IndicatorReport } from "../../domain/entities/IndicatorReport"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import i18n from "../../locales"; +import { buildMonthYearFormatDate } from "../../utils/date"; +import { getCurrentPeriodForProject } from "./IndicatorReportTable"; + +type SpreadSheetOptions = { + indicatorReport: IndicatorReport; + countryName: string; + settings: UniqueBeneficiariesSettings[]; + period: UniqueBeneficiariesPeriod; + year: number; +}; + +export async function buildSpreadSheet(options: SpreadSheetOptions) { + const { indicatorReport, countryName, year } = options; + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(i18n.t("Country Projects & Indicators")); + sheet.columns = generateColumns(); + generateRows(options, sheet); + return generateFileInBuffer(workbook, countryName, indicatorReport, year); +} + +async function generateFileInBuffer( + workbook: ExcelJS.Workbook, + countryName: string, + indicatorReport: IndicatorReport, + year: number +) { + const buffer = await workbook.xlsx.writeBuffer(); + const filename = `${countryName.toLowerCase()}_${indicatorReport.period.name.toLowerCase()}_${year}_report.xlsx`; + return { buffer, filename }; +} + +function generateRows(options: SpreadSheetOptions, sheet: ExcelJS.Worksheet) { + const { indicatorReport, settings, period } = options; + const notAvailableText = i18n.t("N/A"); + indicatorReport.projects.forEach(project => { + const projectDates = `${buildMonthYearFormatDate( + project.project.openingDate + )} - ${buildMonthYearFormatDate(project.project.closedDate)}`; + const currentPeriod = getCurrentPeriodForProject(settings, project.id, period); + + const projectName = `${project.project.name} \r\n (${projectDates}) \r\n Period: ${ + currentPeriod?.name || notAvailableText + }`; + + project.indicators.forEach((indicator, index) => { + sheet.addRow({ + projectName: index === 0 ? projectName : null, + indicatorCode: IndicatorReport.generateIndicatorFullName(indicator), + value: indicator.periodNotAvailable ? notAvailableText : indicator.value, + include: indicator.periodNotAvailable + ? notAvailableText + : indicator.include + ? i18n.t("Yes") + : i18n.t("No"), + total: indicator.periodNotAvailable + ? notAvailableText + : _(project.indicators).sumBy(indicator => + indicator.include ? indicator.value || 0 : 0 + ), + }); + }); + + const startRow = sheet.rowCount - project.indicators.length + 1; + const endRow = sheet.rowCount; + if (project.indicators.length > 1) { + sheet.mergeCells(`A${startRow}:A${endRow}`); + sheet.getCell(`A${startRow}`).alignment = { wrapText: true }; + sheet.mergeCells(`E${startRow}:E${endRow}`); + } + }); + + generateTotalFooter(indicatorReport, sheet); +} + +function generateTotalFooter(indicatorReport: IndicatorReport, sheet: ExcelJS.Worksheet) { + const allIndicators = indicatorReport.projects.flatMap(project => project.indicators); + const totalCountryBeneficiaries = _(allIndicators).sumBy(indicator => + indicator.include ? indicator.value || 0 : 0 + ); + + sheet.addRow({}); + sheet.addRow({ + value: i18n.t("Country Unique Beneficiaries"), + include: totalCountryBeneficiaries, + }); + + const totalRows = sheet.rowCount; + sheet.mergeCells(`D${totalRows}:E${totalRows}`); +} + +function generateColumns(): Partial[] { + return [ + { header: i18n.t("Project"), key: "projectName" }, + { header: i18n.t("Selected Activity Indicators"), key: "indicatorCode" }, + { header: i18n.t("Unique Beneficiaries"), key: "value" }, + { header: i18n.t("Include?"), key: "include" }, + { header: i18n.t("Total Unique Served"), key: "total" }, + ]; +} diff --git a/src/pages/data-approval/DataApproval.tsx b/src/pages/data-approval/DataApproval.tsx index 87414b8c..ac5d1b3b 100644 --- a/src/pages/data-approval/DataApproval.tsx +++ b/src/pages/data-approval/DataApproval.tsx @@ -169,20 +169,9 @@ const DataApproval: React.FC = () => { {projectDataSet && ( - - - diff --git a/src/pages/data-approval/DataApprovalTable.tsx b/src/pages/data-approval/DataApprovalTable.tsx index 0e8273a3..f1131203 100644 --- a/src/pages/data-approval/DataApprovalTable.tsx +++ b/src/pages/data-approval/DataApprovalTable.tsx @@ -1,60 +1,37 @@ import React from "react"; -import { LinearProgress } from "@material-ui/core"; +import { useAppContext } from "../../contexts/api-context"; // @ts-ignore -import { CssVariables } from "@dhis2/ui"; -// @ts-ignore -import { SelectionProvider } from "approval-app/es/selection-context"; -// @ts-ignore -import { AppProvider } from "approval-app/es/app-context"; -// @ts-ignore -import { Layout } from "approval-app/es/app/layout.js"; -// @ts-ignore -import { Display } from "approval-app/es/data-workspace/display"; -// @ts-ignore -import { useSelectionContext } from "approval-app/es/selection-context"; +import { Plugin } from "@dhis2/app-runtime/build/cjs/experimental"; +import { OrganisationUnit } from "../../models/Project"; export interface DataApprovalTableProps { dataSetId: string; - orgUnitId: string; + orgUnit: OrganisationUnit; period: { startDate: string; endDate: string }; attributeOptionComboId: string; } -const DataApprovalTable: React.FC = props => { - return ( - <> - - - - - - - - - ); -}; - -const DataValuesTableContents: React.FC = props => { - const { orgUnitId, dataSetId, period, attributeOptionComboId } = props; - const selection = useSelectionContext(); - const isSelectionFilled = selection.orgUnit && selection.period; - - React.useEffect(() => { - if (isSelectionFilled) return; - selection.selectOrgUnit({ id: orgUnitId }); - selection.selectPeriod(period); - selection.selectFilter(`ao:${attributeOptionComboId}`); - }, [selection, isSelectionFilled, orgUnitId, period, attributeOptionComboId]); - - if (!isSelectionFilled) return ; +export function useDhis2Url(path: string) { + const { api, isDev } = useAppContext(); + return (isDev ? "/dhis2" : api.baseUrl) + path; +} - return ( - - - - - - ); +export const DataApprovalTable: React.FunctionComponent = props => { + const { config } = useAppContext(); + const pluginBaseUrl = useDhis2Url("/dhis-web-approval/plugin.html"); + + const params = { + dataSet: props.dataSetId, + ou: props.orgUnit.path, + ouDisplayName: props.orgUnit.displayName, + pe: props.period.startDate.replace(/-/g, ""), + wf: config.dataApprovalWorkflows.project.id, + hideSelectors: "true", + filter: `ao:${props.attributeOptionComboId}`, + }; + const pluginUrl = pluginBaseUrl + "#/?" + new URLSearchParams(params).toString(); + + return ; }; export default React.memo(DataApprovalTable); diff --git a/src/pages/project-indicators-validation/IndicatorNotification.tsx b/src/pages/project-indicators-validation/IndicatorNotification.tsx new file mode 100644 index 00000000..b7553606 --- /dev/null +++ b/src/pages/project-indicators-validation/IndicatorNotification.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { ConfirmationDialog } from "@eyeseetea/d2-ui-components"; +import i18n from "../../locales"; +import { IndicatorValidation } from "../../domain/entities/IndicatorValidation"; +import { ISODateTimeString } from "../../domain/entities/Ref"; +import { IndicatorCalculation } from "../../domain/entities/IndicatorCalculation"; +import { Typography } from "@material-ui/core"; + +export type IndicatorNotificationProps = { + indicatorValidation: IndicatorValidation; + onClose: () => void; +}; + +export const IndicatorNotification = React.memo((props: IndicatorNotificationProps) => { + const { indicatorValidation, onClose } = props; + + const monthName = getMonthName( + indicatorValidation.lastUpdatedAt + ? indicatorValidation.lastUpdatedAt + : indicatorValidation.createdAt + ); + + const onlyIndicatorsWithChanges = indicatorValidation.indicatorsCalculation.filter( + IndicatorCalculation.hasChanged + ); + + return ( + +
      + {onlyIndicatorsWithChanges.map(indicatorCalculation => { + return ( +
    • + {i18n.t("Indicator Code")} + + {indicatorCalculation.code}: + + + {indicatorCalculation.previousValue} + + + + {indicatorCalculation.nextValue} + +
    • + ); + })} +
    +
    + ); +}); + +IndicatorNotification.displayName = "IndicatorNotification"; + +function getMonthName(dateIso: ISODateTimeString) { + const date = new Date(dateIso); + return date.toLocaleString("en", { month: "long" }); +} diff --git a/src/pages/project-indicators-validation/IndicatorValidationForm.tsx b/src/pages/project-indicators-validation/IndicatorValidationForm.tsx new file mode 100644 index 00000000..95fdfb5e --- /dev/null +++ b/src/pages/project-indicators-validation/IndicatorValidationForm.tsx @@ -0,0 +1,199 @@ +import _ from "lodash"; +import React from "react"; +import { Dropdown } from "@eyeseetea/d2-ui-components"; +import i18n from "../../locales"; +import { IndicatorValidation } from "../../domain/entities/IndicatorValidation"; +import { Maybe } from "../../types/utils"; +import { Button, Grid, makeStyles, Typography } from "@material-ui/core"; +import { IndicatorValidationTable } from "./IndicatorValidationTable"; +import PageHeader from "../../components/page-header/PageHeader"; +import { useHistory } from "react-router-dom"; +import { IndicatorCalculation } from "../../domain/entities/IndicatorCalculation"; +import { UniqueBeneficiariesSettings } from "../../domain/entities/UniqueBeneficiariesSettings"; +import { IndicatorNotification } from "./IndicatorNotification"; +import { ISODateTimeString } from "../../domain/entities/Ref"; +import { useIndicatorValidation } from "./hooks"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; + +export type IndicatorValidationFormProps = { + indicatorsValidation: IndicatorValidation[]; + onSubmit: (indicatorValidation: IndicatorValidation) => void; + onUpdateIndicator: (indicator: IndicatorValidation) => void; + settings: UniqueBeneficiariesSettings; + years: number[]; +}; + +export const IndicatorValidationForm = React.memo((props: IndicatorValidationFormProps) => { + const { indicatorsValidation, onSubmit, settings, onUpdateIndicator, years } = props; + const { periods } = settings; + const [selectedYear, setSelectedYear] = React.useState(); + const [selectedPeriod, setSelectedPeriod] = React.useState(); + const { + dismissNotification, + selectedIndicator, + loadIndicatorValidation, + saveIndicatorValidation, + setDismissNotification, + updateIndicatorsValidationRow, + } = useIndicatorValidation({ + indicatorsValidation, + onSubmit, + periods, + onUpdateIndicator, + selectedPeriod, + }); + + const history = useHistory(); + const classes = useStyles(); + + const mapPeriodsToItems = periods.map(period => ({ + value: period.id, + text: period.name, + })); + + const hasChanged = selectedIndicator?.indicatorsCalculation.some( + IndicatorCalculation.hasChanged + ); + + const commentsNotValid = selectedIndicator?.indicatorsCalculation.some( + IndicatorCalculation.commentIsRequired + ); + + const showNotification = hasChanged ?? false; + + const total = _(selectedIndicator?.indicatorsCalculation).sumBy(indicator => + IndicatorCalculation.getTotal(indicator) + ); + + const mapsYearsToItems = years.map(year => ({ + value: year.toString(), + text: year.toString(), + })); + + const updateYear = React.useCallback( + (value: Maybe) => { + if (value) setSelectedYear(Number(value)); + if (selectedPeriod) loadIndicatorValidation(selectedPeriod.id, Number(value)); + }, + [loadIndicatorValidation, selectedPeriod] + ); + + const updatePeriod = React.useCallback( + (value: Maybe) => { + const period = periods.find(period => period.id === value); + if (period && selectedYear) { + setSelectedPeriod(period); + loadIndicatorValidation(period.id, Number(selectedYear)); + } + }, + [loadIndicatorValidation, periods, selectedYear] + ); + + return ( +
    + history.push("/")} + /> + +
    + + + {selectedYear && ( + + )} + + {selectedIndicator && ( + + + + + + + + + +
    + + {i18n.t("Unique in Project")}: {total} + +
    +
    + + + + +
    + )} + + + {showNotification && selectedIndicator && !dismissNotification && ( + setDismissNotification(true)} + indicatorValidation={selectedIndicator} + /> + )} +
    + ); +}); + +function DateDisplay(props: { label: string; date: Maybe }) { + const { date, label } = props; + + if (!date) return null; + + return ( + + {label}: {convertToLocalDate(date)} + + ); +} + +type FieldValidation = { comment: { isRequired: boolean } }; +export type ErrorValidation = Record; + +function convertToLocalDate(isoDateString: Maybe): string { + if (!isoDateString) return ""; + return new Date(isoDateString).toLocaleString(); +} + +const useStyles = makeStyles({ + alignRight: { marginLeft: "auto" }, + totalContainer: { + display: "flex", + paddingBlock: "0 1em", + paddingInline: "0 1em", + justifyContent: "flex-end", + }, +}); + +IndicatorValidationForm.displayName = "IndicatorValidationForm"; diff --git a/src/pages/project-indicators-validation/IndicatorValidationTable.tsx b/src/pages/project-indicators-validation/IndicatorValidationTable.tsx new file mode 100644 index 00000000..847602da --- /dev/null +++ b/src/pages/project-indicators-validation/IndicatorValidationTable.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { TextField, Tooltip, Typography } from "@material-ui/core"; +import { ObjectsTable, TableConfig } from "@eyeseetea/d2-ui-components"; + +import { + IndicatorCalculation, + IndicatorCalculationAttrs, + IndicatorCalculationKeys, +} from "../../domain/entities/IndicatorCalculation"; +import i18n from "../../locales"; + +export type IndicatorValidationTableProps = { + data: IndicatorCalculation[]; + onRowChange: (value: string, index: number, attributeName: IndicatorCalculationKeys) => void; +}; + +type IndicatorCalculationColumns = IndicatorCalculationAttrs & { total: number }; + +export const IndicatorValidationTable = React.memo((props: IndicatorValidationTableProps) => { + const { data, onRowChange } = props; + + const previousValueLabel = i18n.t("Previous Value"); + const nextValueLabel = i18n.t("Next Value"); + + const config = React.useMemo((): TableConfig => { + return { + actions: [], + columns: [ + { + name: "code", + text: i18n.t("Unique Indicators"), + sortable: false, + getValue(row) { + const hasChanged = IndicatorCalculation.hasChanged(row); + const tooltipTitle = `${previousValueLabel}: ${ + row.previousValue ?? i18n.t("Blank") + } -> ${nextValueLabel}: ${row.nextValue}`; + return ( + + + {row.name} ({row.code}) + + + ); + }, + }, + { + name: "newValue", + text: i18n.t("New"), + sortable: false, + }, + { + name: "editableNewValue", + text: i18n.t("Editable New"), + getValue(row) { + const index = data.findIndex(item => item.id === row.id); + return ( + + onRowChange(event.target.value, index, "editableNewValue") + } + inputProps={{ min: 0 }} + type="number" + /> + ); + }, + sortable: false, + }, + { + name: "returningValue", + text: i18n.t("Returning from previous Project"), + sortable: false, + getValue(row) { + const index = data.findIndex(item => item.id === row.id); + return ( + { + onRowChange(event.target.value, index, "returningValue"); + }} + type="number" + /> + ); + }, + }, + { + name: "total", + text: i18n.t("Total"), + sortable: false, + getValue(row) { + return IndicatorCalculation.getTotal(row); + }, + }, + { + name: "comment", + text: i18n.t("Comment"), + sortable: false, + getValue(row) { + const index = data.findIndex(item => item.id === row.id); + const hasError = IndicatorCalculation.commentIsRequired(row); + const errorMessage = hasError ? i18n.t("Comment is required") : ""; + return ( + + onRowChange(event.target.value, index, "comment") + } + error={hasError} + helperText={errorMessage} + /> + ); + }, + }, + ], + initialSorting: { field: "id", order: "asc" }, + paginationOptions: { pageSizeInitialValue: 100, pageSizeOptions: [] }, + }; + }, [data, nextValueLabel, previousValueLabel, onRowChange]); + + const rows = mapCalculationToTableRows(data); + + return ( + + ); +}); + +function mapCalculationToTableRows(data: IndicatorCalculation[]): IndicatorCalculationColumns[] { + return data.map(item => ({ + ...item, + total: IndicatorCalculation.getTotal(item), + })); +} + +IndicatorValidationTable.displayName = "IndicatorValidationTable"; diff --git a/src/pages/project-indicators-validation/ProjectIndicatorsValidation.tsx b/src/pages/project-indicators-validation/ProjectIndicatorsValidation.tsx new file mode 100644 index 00000000..7165bfac --- /dev/null +++ b/src/pages/project-indicators-validation/ProjectIndicatorsValidation.tsx @@ -0,0 +1,141 @@ +import _ from "lodash"; +import React from "react"; +import { useParams } from "react-router-dom"; +import { useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; + +import { useAppContext } from "../../contexts/api-context"; +import { ISODateTimeString, Ref } from "../../domain/entities/Ref"; +import { useGetUniqueBeneficiaries } from "../../hooks/UniqueBeneficiaries"; +import { IndicatorValidationForm } from "./IndicatorValidationForm"; +import { IndicatorValidation } from "../../domain/entities/IndicatorValidation"; +import i18n from "../../locales"; +import { Id } from "@eyeseetea/d2-api"; +import Project from "../../models/Project"; +import { Maybe } from "../../types/utils"; + +export const ProjectIndicatorsValidation = React.memo(() => { + const { id } = useParams(); + const { compositionRoot } = useAppContext(); + const { project } = useGetProjectById({ id }); + const { settings } = useGetUniqueBeneficiaries({ id, refresh: 0 }); + const loading = useLoading(); + const snackbar = useSnackbar(); + const { indicatorsValidation, setIndicatorsValidation, setRefresh } = + useLoadIndicatorsValidations({ project }); + + const saveIndicator = React.useCallback(() => { + loading.show(true, i18n.t("Saving Indicators...")); + compositionRoot.indicators.saveValidation + .execute({ projectId: id, indicatorsValidations: indicatorsValidation }) + .then(() => { + snackbar.success(i18n.t("Indicators saved")); + setRefresh(prev => prev + 1); + }) + .catch(err => { + snackbar.error(err.message); + }) + .finally(() => loading.hide()); + }, [ + compositionRoot.indicators.saveValidation, + id, + indicatorsValidation, + loading, + snackbar, + setRefresh, + ]); + + const updateIndicator = React.useCallback( + (indicatorValidation: IndicatorValidation) => { + setIndicatorsValidation(prev => + prev.map(indicator => { + return indicator.checkPeriodAndYear( + indicatorValidation.period.id, + indicatorValidation.year + ) + ? indicatorValidation + : indicator; + }) + ); + }, + [setIndicatorsValidation] + ); + + if (!settings || !project) return null; + + const years = getYearsFromProject( + project.startDate?.toISOString() || "", + project.endDate?.toISOString() || "" + ); + + return ( +
    + +
    + ); +}); + +export function getYearsFromProject( + startDate: ISODateTimeString, + endDate: ISODateTimeString +): number[] { + if (!startDate || !endDate) return []; + const startYear = new Date(startDate).getFullYear(); + const endYear = new Date(endDate).getFullYear(); + return _.range(startYear, endYear + 1); +} + +function useGetProjectById(props: { id: Id }) { + const { id } = props; + const { compositionRoot } = useAppContext(); + const [project, setProject] = React.useState(); + + const loading = useLoading(); + const snackbar = useSnackbar(); + + React.useEffect(() => { + loading.show(true, i18n.t("Loading Project")); + compositionRoot.projects.getById + .execute(id) + .then(setProject) + .catch(err => { + snackbar.error(err.message); + }) + .finally(() => loading.hide()); + }, [compositionRoot.projects.getById, id, loading, snackbar]); + + return { project }; +} + +function useLoadIndicatorsValidations(props: { project: Maybe }) { + const { project } = props; + const { compositionRoot } = useAppContext(); + const [indicatorsValidation, setIndicatorsValidation] = React.useState( + [] + ); + const [refresh, setRefresh] = React.useState(0); + const loading = useLoading(); + const snackbar = useSnackbar(); + + React.useEffect(() => { + if (!project) return; + console.debug("refresh", refresh); + loading.show(true, i18n.t("Loading Indicators...")); + compositionRoot.indicators.getValidation + .execute({ projectId: project.id }) + .then(setIndicatorsValidation) + .catch(err => { + snackbar.error(err.message); + }) + .finally(() => loading.hide()); + }, [compositionRoot.indicators, loading, project, refresh, snackbar]); + + return { indicatorsValidation, setIndicatorsValidation, setRefresh }; +} + +ProjectIndicatorsValidation.displayName = "ProjectIndicatorsValidation"; diff --git a/src/pages/project-indicators-validation/hooks.ts b/src/pages/project-indicators-validation/hooks.ts new file mode 100644 index 00000000..0569930d --- /dev/null +++ b/src/pages/project-indicators-validation/hooks.ts @@ -0,0 +1,117 @@ +import { useSnackbar } from "@eyeseetea/d2-ui-components"; +import React from "react"; +import { getErrors } from "../../domain/entities/generic/Errors"; +import { + IndicatorCalculation, + IndicatorCalculationKeys, +} from "../../domain/entities/IndicatorCalculation"; +import { IndicatorValidation } from "../../domain/entities/IndicatorValidation"; +import { UniqueBeneficiariesPeriod } from "../../domain/entities/UniqueBeneficiariesPeriod"; +import i18n from "../../locales"; +import { Maybe } from "../../types/utils"; + +export type UseIndicatorValidationProps = { + periods: UniqueBeneficiariesPeriod[]; + indicatorsValidation: IndicatorValidation[]; + onSubmit: (indicatorValidation: IndicatorValidation) => void; + onUpdateIndicator: (indicator: IndicatorValidation) => void; + selectedPeriod: Maybe; +}; + +export function useIndicatorValidation(props: UseIndicatorValidationProps) { + const { indicatorsValidation, onSubmit, onUpdateIndicator, periods, selectedPeriod } = props; + + const [selectedIndicator, setIndicatorValidation] = React.useState(); + const [dismissNotification, setDismissNotification] = React.useState(false); + const snackbar = useSnackbar(); + const loadIndicatorValidation = React.useCallback( + (period: string, year: number) => { + const uniquePeriod = periods.find(item => item.id === period); + if (!uniquePeriod) { + snackbar.error(i18n.t("Period not found")); + return; + } + + const currentIndicatorValidation = indicatorsValidation.find(indicator => + indicator.checkPeriodAndYear(uniquePeriod.id, Number(year)) + ); + + setDismissNotification(false); + IndicatorValidation.build({ + year: Number(year), + createdAt: currentIndicatorValidation?.createdAt || "", + lastUpdatedAt: currentIndicatorValidation?.lastUpdatedAt, + period: uniquePeriod, + indicatorsCalculation: currentIndicatorValidation?.indicatorsCalculation || [], + }).match({ + success: setIndicatorValidation, + error: err => { + const errorMessage = getErrors(err); + snackbar.error(errorMessage); + }, + }); + }, + [indicatorsValidation, periods, snackbar] + ); + + const updateIndicatorsValidationRow = React.useCallback( + (value: string, indexRow: number, attributeName: IndicatorCalculationKeys) => { + setIndicatorValidation(prevState => { + if (!prevState) return prevState; + + if (attributeName === "editableNewValue" || attributeName === "returningValue") { + const numericValue = Number(value); + if (numericValue < 0) { + snackbar.error(i18n.t("Value must be greater than or equal to zero")); + return prevState; + } + } + + const record = IndicatorValidation.build({ + ...prevState, + indicatorsCalculation: prevState.indicatorsCalculation.map((item, index) => { + if (index !== indexRow) return item; + return IndicatorCalculation.build({ + ...item, + [attributeName]: getValue(value, attributeName), + }).get(); + }), + }).get(); + onUpdateIndicator(record); + return record; + }); + }, + [onUpdateIndicator, snackbar] + ); + + const saveIndicatorValidation = React.useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + if (!selectedPeriod || !selectedIndicator) return; + onSubmit(selectedIndicator); + }, + [selectedIndicator, onSubmit, selectedPeriod] + ); + + return { + selectedIndicator, + selectedPeriod, + dismissNotification, + setDismissNotification, + loadIndicatorValidation, + updateIndicatorsValidationRow, + saveIndicatorValidation, + }; +} + +function getValue(value: string, attributeName: IndicatorCalculationKeys) { + switch (attributeName) { + case "editableNewValue": + case "returningValue": + return value.length > 0 ? Number(value) : undefined; + case "comment": + return value; + default: + throw new Error(`Attribute ${attributeName} not supported`); + } +} diff --git a/src/pages/project-wizard/ProjectWizard.tsx b/src/pages/project-wizard/ProjectWizard.tsx index ed9b505c..86cd2c6f 100644 --- a/src/pages/project-wizard/ProjectWizard.tsx +++ b/src/pages/project-wizard/ProjectWizard.tsx @@ -1,11 +1,11 @@ import React from "react"; import { useLocation } from "react-router"; import _ from "lodash"; -import { Wizard, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Wizard, useSnackbar, ConfirmationDialog } from "@eyeseetea/d2-ui-components"; import { LinearProgress } from "@material-ui/core"; import { Location } from "history"; -import Project, { ValidationKey } from "../../models/Project"; +import Project, { ProjectAction, ValidationKey } from "../../models/Project"; import { D2Api } from "../../types/d2-api"; import { generateUrl } from "../../router"; import i18n from "../../locales"; @@ -27,8 +27,9 @@ import MerSelectionStep from "../../components/steps/mer-selection/MerSelectionS import { useAppHistory } from "../../utils/use-app-history"; import { Maybe } from "../../types/utils"; import { AttachFilesStep } from "../../components/steps/attach-files/AttachFilesStep"; +import UniqueIndicatorsStep from "../../components/steps/unique-beneficiaries/UniqueIndicatorsStep"; -type Action = { type: "create" } | { type: "edit"; id: string }; +type Action = { type: "create" } | { type: "edit"; id: string } | { type: "clone"; id: string }; interface ProjectWizardProps { action: Action; @@ -39,7 +40,7 @@ export interface StepProps { project: Project; onChange: (project: Project) => void; onCancel: () => void; - action: "create" | "update"; + action: ProjectAction; } interface Props { @@ -56,6 +57,7 @@ interface State { project: Project | undefined; dialogOpen: boolean; isUpdated: boolean; + showCloneWarning: boolean; } interface Step { @@ -73,17 +75,13 @@ class ProjectWizardImpl extends React.Component { project: undefined, dialogOpen: false, isUpdated: false, + showCloneWarning: true, }; async componentDidMount() { - const { api, config, action, isDev } = this.props; - try { - const project = - action.type === "create" - ? getDevProject(Project.create(api, config), isDev) - : await Project.get(api, config, action.id); - this.setState({ project }); + const project = await this.getInitialProjectData(); + this.setState({ project: project }); } catch (err: any) { console.error(err); this.props.snackbar.error(i18n.t("Cannot load project") + `: ${err.message || err}`); @@ -91,10 +89,27 @@ class ProjectWizardImpl extends React.Component { } } + getInitialProjectData = async () => { + switch (this.props.action.type) { + case "create": { + const project = Project.create(this.props.api, this.props.config); + return getDevProject(project, this.props.isDev); + } + case "edit": + return Project.get(this.props.api, this.props.config, this.props.action.id); + case "clone": + return Project.clone(this.props.api, this.props.config, this.props.action.id); + } + }; + isEdit() { return this.props.action.type === "edit"; } + isClone() { + return this.props.action.type === "clone"; + } + getStepsBaseInfo(): Step[] { const { project } = this.state; if (!project) return []; @@ -138,7 +153,7 @@ class ProjectWizardImpl extends React.Component { }, { key: "indicators", - label: i18n.t("Selection of Indicators"), + label: i18n.t("Indicators"), component: DataElementsSelectionStep, validationKeys: ["dataElementsSelection"], help: helpTexts.indicators, @@ -154,11 +169,18 @@ class ProjectWizardImpl extends React.Component { : null, { key: "mer-indicators", - label: i18n.t("Selection of MER Indicators"), + label: i18n.t("MER Indicators"), component: MerSelectionStep, validationKeys: ["dataElementsMER"], help: helpTexts.merIndicators, }, + { + key: "unique-beneficiaries", + label: i18n.t("Unique Indicators"), + component: UniqueIndicatorsStep, + validationKeys: ["uniqueIndicators"], + help: helpTexts.uniqueIndicators, + }, { key: "sharing", label: i18n.t("Username Access"), @@ -220,8 +242,19 @@ class ProjectWizardImpl extends React.Component { return await getValidationMessages(this.state.project, currentStep.validationKeys); }; + getTitle = (): string => { + switch (this.props.action.type) { + case "edit": + return i18n.t("Edit project"); + case "clone": + return i18n.t("Clone project"); + case "create": + return i18n.t("New project"); + } + }; + render() { - const { project, dialogOpen } = this.state; + const { project, dialogOpen, showCloneWarning } = this.state; const { api, location, action } = this.props; if (project) Object.assign(window, { project, Project }); @@ -240,8 +273,8 @@ class ProjectWizardImpl extends React.Component { const stepExists = steps.find(step => step.key === urlHash); const firstStepKey = steps.map(step => step.key)[0]; const initialStepKey = stepExists ? urlHash : firstStepKey; - const lastClickableStepIndex = this.isEdit() ? steps.length - 1 : 0; - const title = this.isEdit() ? i18n.t("Edit project") : i18n.t("New project"); + const lastClickableStepIndex = this.isEdit() || this.isClone() ? steps.length - 1 : 0; + const title = this.getTitle(); return ( @@ -254,6 +287,17 @@ class ProjectWizardImpl extends React.Component { title={`${title}: ${project ? project.name : i18n.t("Loading...")}`} onBackClick={this.cancelSave} /> + + this.setState({ showCloneWarning: false })} + /> + {project ? ( { const canCreateProjects = currentUser.can("create"); const goToNewProject = React.useCallback(() => goTo("projects.new"), [goTo]); const newProjectPageHandler = canCreateProjects ? goToNewProject : undefined; + const goToCountryIndicatorReport = React.useCallback( + () => goTo("countryIndicatorsReport"), + [goTo] + ); const onAttachModalClose = () => { setActionSelected(undefined); @@ -117,20 +121,23 @@ const ProjectsList: React.FC = () => { } /> - {canAccessReports && ( - - )} +
    + {canAccessReports && ( + + )} + + {newProjectPageHandler && ( + + )} - {newProjectPageHandler && ( - )} +
    @@ -138,8 +145,8 @@ const ProjectsList: React.FC = () => { ); }; -const styles = { - merReports: { marginLeft: 30, marginRight: 20 }, +const styles: Record = { + actionsStyles: { display: "flex", gap: "1em" }, }; const ObjectsListStyled = styled(ObjectsList)` diff --git a/src/pages/projects-list/ProjectsListConfig.tsx b/src/pages/projects-list/ProjectsListConfig.tsx index 77bb23e0..86e9ce10 100644 --- a/src/pages/projects-list/ProjectsListConfig.tsx +++ b/src/pages/projects-list/ProjectsListConfig.tsx @@ -112,6 +112,21 @@ export function getComponentConfig( ]; const allActions: Record> = { + projectIndicators: { + name: "projectIndicators", + text: i18n.t("Project Indicators Validation"), + icon: done_all, + multiple: false, + onClick: (ids: Id[]) => onFirst(ids, id => goTo("projectIndicatorsValidation", { id })), + }, + periods: { + name: "periods", + text: i18n.t("Manage Unique Beneficiaries Periods"), + icon: date_range, + multiple: false, + primary: true, + onClick: (ids: Id[]) => onFirst(ids, id => goTo("uniqueBeneficiariesPeriods", { id })), + }, details: { name: "details", text: i18n.t("Details"), @@ -167,6 +182,13 @@ export function getComponentConfig( multiple: false, onClick: (ids: Id[]) => onFirst(ids, id => goTo("projects.edit", { id })), }, + clone: { + name: "clone", + icon: content_copy, + text: i18n.t("Clone"), + multiple: false, + onClick: (ids: Id[]) => onFirst(ids, id => goTo("projects.clone", { id })), + }, delete: { name: "delete", diff --git a/src/pages/report/MerReport.tsx b/src/pages/report/MerReport.tsx index 922eb329..94f927d8 100644 --- a/src/pages/report/MerReport.tsx +++ b/src/pages/report/MerReport.tsx @@ -37,8 +37,13 @@ const MerReportComponent: React.FC = () => { const classes = useStyles(); const snackbar = useSnackbar(); const initial = isDev ? getDevMerReport() : { date: null, orgUnit: null }; - const [proceedWarning, setProceedWarning] = useState({ type: "hidden" }); - const [wasReportModified, wasReportModifiedSet] = useState(false); + const { + confirmIfUnsavedChanges, + proceedWarning, + runProceedAction, + wasReportModified, + wasReportModifiedSet, + } = useConfirmChanges(); const datePickerState = useBoolean(false); const [date, setDate] = useState(initial.date); const [orgUnit, setOrgUnit] = useState(initial.orgUnit); @@ -57,12 +62,15 @@ const MerReportComponent: React.FC = () => { MerReport.create(api, config, selectData).then(setMerReportBase) ); } - }, [api, config, snackbar, date, orgUnit]); + }, [api, config, snackbar, date, orgUnit, wasReportModifiedSet]); - const setMerReport = React.useCallback((report: MerReport) => { - setMerReportBase(report); - wasReportModifiedSet(true); - }, []); + const setMerReport = React.useCallback( + (report: MerReport) => { + setMerReportBase(report); + wasReportModifiedSet(true); + }, + [wasReportModifiedSet] + ); const onChange = React.useCallback( (field: Field, val: MerReportData[Field]) => { @@ -95,25 +103,6 @@ const MerReportComponent: React.FC = () => { if (merReport) run(merReport); }, [merReport, snackbar, wasReportModifiedSet, loading]); - const confirmIfUnsavedChanges = React.useCallback( - (action: () => void) => { - if (wasReportModified) { - setProceedWarning({ type: "visible", action }); - } else { - action(); - } - }, - [wasReportModified, setProceedWarning] - ); - - const runProceedAction = React.useCallback( - (action: () => void) => { - setProceedWarning({ type: "hidden" }); - action(); - }, - [setProceedWarning] - ); - const setDateAndClosePicker = React.useCallback( (date: Moment) => { setDate(date); @@ -290,3 +279,35 @@ function useRedirectToProjectsPageIfUserHasNoAccess() { } export default React.memo(MerReportComponent); + +export function useConfirmChanges() { + const [proceedWarning, setProceedWarning] = React.useState({ type: "hidden" }); + const [wasReportModified, wasReportModifiedSet] = React.useState(false); + + const confirmIfUnsavedChanges = React.useCallback( + (action: () => void) => { + if (wasReportModified) { + setProceedWarning({ type: "visible", action }); + } else { + action(); + } + }, + [wasReportModified, setProceedWarning] + ); + + const runProceedAction = React.useCallback( + (action: () => void) => { + setProceedWarning({ type: "hidden" }); + action(); + }, + [setProceedWarning] + ); + + return { + confirmIfUnsavedChanges, + proceedWarning, + runProceedAction, + wasReportModified, + wasReportModifiedSet, + }; +} diff --git a/src/pages/root/Root.tsx b/src/pages/root/Root.tsx index 6fdc6429..12d95212 100644 --- a/src/pages/root/Root.tsx +++ b/src/pages/root/Root.tsx @@ -12,6 +12,9 @@ import ProjectDashboard from "../dashboard/ProjectDashboard"; import CountryDashboard from "../dashboard/CountryDashboard"; import AwardNumberDashboard from "../dashboard/AwardNumberDashboard"; import { LastLocationProvider } from "react-router-last-location"; +import { UniqueBeneficiariesPeriodsPage } from "../unique-periods/UniqueBeneficiariesPeriodsPage"; +import { ProjectIndicatorsValidation } from "../project-indicators-validation/ProjectIndicatorsValidation"; +import { CountryIndicatorReport } from "../country-indicator-report/CountryIndicatorReport"; const Root = () => { const idParam = { id: ":id" }; @@ -32,6 +35,14 @@ const Root = () => { /> )} /> + ( + + )} + /> } /> { path={generateUrl("dataApproval", { id: ":id" })} render={() => } /> + } + /> + + } + /> + } /> + } + /> + } /> diff --git a/src/pages/unique-periods/UniqueBeneficiariesPeriodsPage.tsx b/src/pages/unique-periods/UniqueBeneficiariesPeriodsPage.tsx new file mode 100644 index 00000000..80dec63c --- /dev/null +++ b/src/pages/unique-periods/UniqueBeneficiariesPeriodsPage.tsx @@ -0,0 +1,148 @@ +import React from "react"; +import { ConfirmationDialog, useLoading, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Dialog, DialogContent, DialogTitle } from "@material-ui/core"; +import { useParams } from "react-router-dom"; +import PageHeader from "../../components/page-header/PageHeader"; +import { + ActionTable, + UniqueBeneficiariesTable, +} from "../../components/unique-beneficiaries/UniqueBeneficiariesTable"; +import { useAppContext } from "../../contexts/api-context"; +import { Ref } from "../../domain/entities/Ref"; +import { + UniqueBeneficiariesPeriod, + UniqueBeneficiariesPeriodsAttrs, +} from "../../domain/entities/UniqueBeneficiariesPeriod"; +import i18n from "../../locales"; +import { UniquePeriodsForm } from "./UniquePeriodsForm"; +import { useGetUniqueBeneficiaries } from "../../hooks/UniqueBeneficiaries"; +import { useGoTo } from "../../router"; + +export const UniqueBeneficiariesPeriodsPage = React.memo(() => { + const { compositionRoot } = useAppContext(); + const { id } = useParams(); + const goTo = useGoTo(); + const snackbar = useSnackbar(); + const loading = useLoading(); + const [savePeriodModal, setSavePeriodModal] = React.useState(false); + const [deleteModal, setDeleteModal] = React.useState(false); + const [selectedPeriod, setSelectedPeriod] = React.useState(); + const [refresh, setRefresh] = React.useState(1); + + const { settings } = useGetUniqueBeneficiaries({ id, refresh }); + + const openSavePeriodDialog = React.useCallback( + (options: ActionTable) => { + setSelectedPeriod(settings?.periods.find(period => period.id === options.id)); + switch (options.action) { + case "add": + case "edit": + setSavePeriodModal(true); + break; + case "delete": + setDeleteModal(true); + break; + default: + break; + } + }, + [settings?.periods] + ); + + const savePeriod = React.useCallback( + (periodData: UniqueBeneficiariesPeriod) => { + loading.show(true, i18n.t("Saving Period...")); + compositionRoot.uniqueBeneficiaries.saveSettings + .execute({ period: periodData, projectId: id }) + .then(() => { + snackbar.success(i18n.t("Period saved successfully")); + setRefresh(refresh + 1); + setSavePeriodModal(false); + setSelectedPeriod(undefined); + }) + .catch(err => { + snackbar.error(err.message); + }) + .finally(() => { + loading.hide(); + }); + }, + [compositionRoot.uniqueBeneficiaries.saveSettings, id, loading, refresh, snackbar] + ); + + const removePeriod = React.useCallback(() => { + if (!selectedPeriod) return; + const period = UniqueBeneficiariesPeriod.build(selectedPeriod).get(); + loading.show(true, i18n.t("Removing Period...")); + compositionRoot.uniqueBeneficiaries.removePeriod + .execute({ projectId: id, period }) + .then(() => { + setRefresh(refresh + 1); + snackbar.success(i18n.t("Period removed successfully")); + }) + .catch(err => { + snackbar.error(err.message); + }) + .finally(() => { + setDeleteModal(false); + setSelectedPeriod(undefined); + loading.hide(); + }); + }, [ + compositionRoot.uniqueBeneficiaries.removePeriod, + id, + loading, + refresh, + selectedPeriod, + snackbar, + ]); + + return ( +
    + goTo("projects")} + /> + + + + + + {i18n.t("{{actionPeriod}} Period", { + actionPeriod: selectedPeriod ? i18n.t("Edit") : i18n.t("Create"), + })} + + + { + setSavePeriodModal(false); + setSelectedPeriod(undefined); + }} + /> + + + + { + setDeleteModal(false); + setSelectedPeriod(undefined); + }} + saveText={i18n.t("Delete")} + cancelText={i18n.t("Cancel")} + /> +
    + ); +}); + +UniqueBeneficiariesPeriodsPage.displayName = "UniqueBeneficiariesPeriodsPage"; diff --git a/src/pages/unique-periods/UniquePeriodsForm.tsx b/src/pages/unique-periods/UniquePeriodsForm.tsx new file mode 100644 index 00000000..198ba202 --- /dev/null +++ b/src/pages/unique-periods/UniquePeriodsForm.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import { Dropdown, useSnackbar } from "@eyeseetea/d2-ui-components"; +import { Button, TextField } from "@material-ui/core"; +import { makeStyles, createStyles } from "@material-ui/styles"; + +import { + UniqueBeneficiariesPeriod, + UniqueBeneficiariesPeriodsAttrs, +} from "../../domain/entities/UniqueBeneficiariesPeriod"; +import i18n from "../../locales"; +import { Maybe } from "../../types/utils"; +import { getErrors } from "../../domain/entities/generic/Errors"; + +export type UniquePeriodsFormProps = { + existingPeriod?: UniqueBeneficiariesPeriodsAttrs; + onClose: () => void; + onSubmit: (uniquePeriods: UniqueBeneficiariesPeriod) => void; +}; + +export const months = [ + { value: "1", text: i18n.t("January") }, + { value: "2", text: i18n.t("February") }, + { value: "3", text: i18n.t("March") }, + { value: "4", text: i18n.t("April") }, + { value: "5", text: i18n.t("May") }, + { value: "6", text: i18n.t("June") }, + { value: "7", text: i18n.t("July") }, + { value: "8", text: i18n.t("August") }, + { value: "9", text: i18n.t("September") }, + { value: "10", text: i18n.t("October") }, + { value: "11", text: i18n.t("November") }, + { value: "12", text: i18n.t("December") }, +]; + +function getValueByAttribute( + value: string, + attribute: keyof UniqueBeneficiariesPeriod +): number | string { + return attribute === "endDateMonth" || attribute === "startDateMonth" ? Number(value) : value; +} + +export const UniquePeriodsForm = React.memo((props: UniquePeriodsFormProps) => { + const { existingPeriod, onClose, onSubmit } = props; + const snackbar = useSnackbar(); + const classes = useStyles(); + const [uniquePeriod, setUniquePeriod] = React.useState( + existingPeriod || UniqueBeneficiariesPeriod.initialPeriodData() + ); + + const validateAndSubmit = React.useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + UniqueBeneficiariesPeriod.build(uniquePeriod).match({ + success: period => onSubmit(period), + error: errors => { + const errorMessage = getErrors(errors); + snackbar.error(errorMessage, { autoHideDuration: 3000 }); + }, + }); + }, + [onSubmit, snackbar, uniquePeriod] + ); + + const updatePeriod = React.useCallback( + (value: Maybe, attribute: keyof UniqueBeneficiariesPeriod) => { + setUniquePeriod(prev => { + if (!prev) return prev; + return { ...prev, [attribute]: getValueByAttribute(value || "", attribute) }; + }); + }, + [] + ); + + return ( +
    + updatePeriod(event.target.value, "name")} + value={uniquePeriod.name} + /> + updatePeriod(value, "startDateMonth")} + value={uniquePeriod.startDateMonth.toString()} + /> + updatePeriod(value, "endDateMonth")} + value={uniquePeriod.endDateMonth.toString()} + /> + +
    + + +
    + + ); +}); + +const useStyles = makeStyles(() => + createStyles({ + form: { display: "flex", flexDirection: "column", gap: "1rem" }, + buttonContainer: { display: "flex", alignItems: "center", justifyContent: "space-between" }, + }) +); + +UniquePeriodsForm.displayName = "UniquePeriodsForm"; diff --git a/src/router.ts b/src/router.ts index 4f5cd473..3c8ed58c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -6,6 +6,7 @@ const routes = { projects: () => "/", "projects.new": () => `/projects/new`, "projects.edit": ({ id }: { id: string }) => `/projects/edit/${id}`, + "projects.clone": ({ id }: { id: string }) => `/projects/clone/${id}`, report: () => "/report", actualValues: ({ id }: { id: string }) => `/actual-values/${id}`, targetValues: ({ id }: { id: string }) => `/target-values/${id}`, @@ -14,6 +15,9 @@ const routes = { countryDashboard: ({ id }: { id: string }) => `/country-dashboard/${id}`, dataApproval: dataApproval, countries: () => `/countries`, + uniqueBeneficiariesPeriods: ({ id }: { id: string }) => `/unique-beneficiaries-periods/${id}`, + projectIndicatorsValidation: ({ id }: { id: string }) => `/project-indicators-validation/${id}`, + countryIndicatorsReport: () => "/country-indicators-report", }; type Routes = typeof routes; diff --git a/src/scripts/import-data-element-excel.ts b/src/scripts/import-data-element-excel.ts index 0b6d2919..9c4b803d 100644 --- a/src/scripts/import-data-element-excel.ts +++ b/src/scripts/import-data-element-excel.ts @@ -2,7 +2,13 @@ import parse from "parse-typed-args"; import { getApp } from "./common"; import { getConfig } from "../models/Config"; -import { getCompositionRoot } from "../CompositionRoot"; +import { DataValueExportJsonRepository } from "../data/repositories/DataValueExportJsonRepository"; +import { ExportDataElementJsonRepository } from "../data/repositories/ExportDataElementJsonRepository"; +import { ImportDataElementSpreadSheetRepository } from "../data/repositories/ImportDataElementSpreadSheetRepository"; +import { OrgUnitD2Repository } from "../data/repositories/OrgUnitD2Repository"; +import { ImportDataElementsUseCase } from "../domain/usecases/ImportDataElementsUseCase"; +import { DataElementD2Repository } from "../data/repositories/DataElementD2Repository"; +import { DataValueD2Repository } from "../data/repositories/DataValueD2Repository"; async function main() { const parser = parse({ @@ -14,6 +20,7 @@ async function main() { deleteDataValues: { switch: true }, }, }); + const { opts } = parser(process.argv); if (!opts.url) return; @@ -22,8 +29,24 @@ async function main() { const { api } = await getApp({ baseUrl: opts.url }); console.info("Loading config. metadata..."); const config = await getConfig(api); - const compositionRoot = getCompositionRoot(api, config); - await compositionRoot.dataElements.import.execute({ + + const dataValueRepository = new DataValueD2Repository(api); + const dataElementRepository = new DataElementD2Repository(api, config); + const importRepository = new ImportDataElementSpreadSheetRepository(api, config); + const exportDataElementJsonRepository = new ExportDataElementJsonRepository(api, config); + const dataValueExportRepository = new DataValueExportJsonRepository(); + const orgUnitRepository = new OrgUnitD2Repository(api); + + const importDataElementUseCase = new ImportDataElementsUseCase( + importRepository, + dataElementRepository, + exportDataElementJsonRepository, + dataValueRepository, + dataValueExportRepository, + orgUnitRepository + ); + + await importDataElementUseCase.execute({ excelPath: opts.excelPath, post: opts.post ?? false, export: opts.export ?? false, diff --git a/src/utils/date.ts b/src/utils/date.ts index 6e216d93..8180d29c 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,4 +1,5 @@ import moment, { Moment } from "moment"; +import { months } from "../pages/unique-periods/UniquePeriodsForm"; export function toISOString(date: Moment) { return date.format("YYYY-MM-DDTHH:mm:ss"); @@ -48,3 +49,12 @@ export const monthFormat = "YYYYMM"; export function getPeriodIds(range: Moment[]): Array<{ id: string }> { return range.map(m => ({ id: m.format(monthFormat) })); } + +export function buildMonthYearFormatDate(dateIsoString: string): string { + // examples: JAN 2021, NOV 2024 + return new Date(dateIsoString).toLocaleString("default", { month: "short", year: "numeric" }); +} + +export function getMonthNameFromNumber(monthNumber: string | number): string { + return months.find(month => month.value === monthNumber.toString())?.text || ""; +} diff --git a/yarn.lock b/yarn.lock index 84db09d8..8c445021 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2820,40 +2820,48 @@ classnames "^2.3.1" prop-types "^15.7.2" -"@dhis2/app-runtime@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-3.2.1.tgz#c8c8ec386b1e27502c42c9481eb8b97947bebe7f" - integrity sha512-sQ3PAla1wlp8kzy8hOkhzfDtIP1+0r7pbKB2l9QxfDAlQntOy1aUC3hx8JIPPx5hB9hX1lwf0EPbuYH9zp+LDA== - dependencies: - "@dhis2/app-service-alerts" "3.2.1" - "@dhis2/app-service-config" "3.2.1" - "@dhis2/app-service-data" "3.2.1" - "@dhis2/app-service-offline" "3.2.1" - -"@dhis2/app-service-alerts@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-alerts/-/app-service-alerts-3.2.1.tgz#7118e157b1b60b6d08df899ed3d2bef6efb056c7" - integrity sha512-XITsLOCVqxl3nFLp5oYbuwuyL9RVvQu1U5z5TXFD4B/9sZE2zdNw9FagzQkQbAWoeiNDVsoHbB4KP7rZhyDQ+Q== - -"@dhis2/app-service-config@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-config/-/app-service-config-3.2.1.tgz#9e915bad721bb4cf6c6f9242ca2d959b21f78a8a" - integrity sha512-AzPrUuQ4MOXEpYUm5iMbCaxkjnzQVej4n5exNirolVEqUaE2NyGYYDqXp0GhxHq/q5QIhbSKqLL8NES/kDOusw== - -"@dhis2/app-service-data@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-data/-/app-service-data-3.2.1.tgz#a0a3df618f99966a3511260c7ceed41cd236d4e7" - integrity sha512-UW3e/Gr/hBNkT/gSlJ/h49rUYJ1ws+6osNZ7Q9hI5JOGHlqPtmKcWhuHfLUMeljRs2Bvan7f5duAycssSddt0A== +"@dhis2/app-runtime@3.10.4": + version "3.10.4" + resolved "https://registry.yarnpkg.com/@dhis2/app-runtime/-/app-runtime-3.10.4.tgz#6064ac728770cc94c4d1975db32bd38533655cc6" + integrity sha512-W/d0WcYYcKAeE5/xCunZEMYUSD1fxG+JDQdRDEUsH5y5hB8i/4o2QQrZK8xa19Z3xQJhaW5ypWWqIQVjTJT2Ww== + dependencies: + "@dhis2/app-service-alerts" "3.10.4" + "@dhis2/app-service-config" "3.10.4" + "@dhis2/app-service-data" "3.10.4" + "@dhis2/app-service-offline" "3.10.4" + "@dhis2/app-service-plugin" "3.10.4" + +"@dhis2/app-service-alerts@3.10.4": + version "3.10.4" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-alerts/-/app-service-alerts-3.10.4.tgz#a7cce660015d79980679175e677cf6c6c3f4c7eb" + integrity sha512-DmSLx/kHOHpgGiL8zG0oa6D3MeCY3wPMDGqj+Gfegr654Lmyf4d2vLI7HSZUSOCdraP/fSYTypsdZmWYoXoLBQ== + +"@dhis2/app-service-config@3.10.4": + version "3.10.4" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-config/-/app-service-config-3.10.4.tgz#2bede4df9b036350200cbc085a2a68439fa4fa0a" + integrity sha512-SCFdNxJKpiBjYsU9s0R+u9GrXjzmUEpGpudmC5eQqNV6ajLiebe/pS2jcSPFzjUtHVQMADk0X8TkERoOBqWcxA== + +"@dhis2/app-service-data@3.10.4": + version "3.10.4" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-data/-/app-service-data-3.10.4.tgz#dcd993a24d2edd97e7ad8a042a682e60582740ff" + integrity sha512-RaoWniioCe33PcPZoDuO66qOrIt0JeeCN8RzmCDrRVGvGmlDTZ3hNuWm9CnsAvO1U6sYDiulKPqrEQu8YKMG0w== dependencies: react-query "^3.13.11" -"@dhis2/app-service-offline@3.2.1": - version "3.2.1" - resolved "https://registry.yarnpkg.com/@dhis2/app-service-offline/-/app-service-offline-3.2.1.tgz#92b6f1c9c15505b6d333adb1623b05ba78879469" - integrity sha512-NmxwwP2udTUvEydxvG+COk0bSmlMU9oaX9ji3UblINUewR93oB+APKAks+pAD9CCK3t6P+npGSUyFpQWyzaQMg== +"@dhis2/app-service-offline@3.10.4": + version "3.10.4" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-offline/-/app-service-offline-3.10.4.tgz#dcdaf3a76c6c7a71f28c89a403ec130bf10fcdf3" + integrity sha512-SUUS+sw3FjR0TMdKSSOVzL8IfPA185gSTHQ1WE4tLc2zE92elvxLL9FuSUuTbu+l1Kr4nRQNLh8muOMWKGnWDg== dependencies: lodash "^4.17.21" +"@dhis2/app-service-plugin@3.10.4": + version "3.10.4" + resolved "https://registry.yarnpkg.com/@dhis2/app-service-plugin/-/app-service-plugin-3.10.4.tgz#f5412a1320393042012dd75713e3215e51628554" + integrity sha512-GW6xa/5y2yFXvhtLConnaOxKqyu6VPZWRBaQR73/bRRmFcnd7hlMkZ2M0GWSjoW4QHLXlLhXHFsgHPETBftkPg== + dependencies: + post-robot "^10.0.46" + "@dhis2/d2-i18n-extract@1.0.8": version "1.0.8" resolved "https://registry.yarnpkg.com/@dhis2/d2-i18n-extract/-/d2-i18n-extract-1.0.8.tgz#9d98690d522a51895c8ef3fe7136f026b0f8dacd" @@ -3406,10 +3414,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@eyeseetea/d2-api@1.14.0": - version "1.14.0" - resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.14.0.tgz#546398b00b9f01b60a72ecba648a35f57b6f7559" - integrity sha512-gVNXfK8sk1STuM8QDed0JY8DM63SwI3UJXeKsyzJjHtPIqi76ukdCYAo8XwtfqhGdZIwIMrrWPEOmAJ3dbvCKQ== +"@eyeseetea/d2-api@1.16.0-beta.13": + version "1.16.0-beta.13" + resolved "https://registry.yarnpkg.com/@eyeseetea/d2-api/-/d2-api-1.16.0-beta.13.tgz#1b46c30c18f5f54339c7e801af18ad941ea028e7" + integrity sha512-HtQMSDgIKOaMuS1qqBV6BN5uy6AIVbsyqLsrrF3RrWPUcxZmsZyKqM955F1IYbOROLjEgxu5zJnI2B7n4YDKpA== dependencies: "@babel/runtime" "^7.5.4" "@dhis2/d2-i18n" "^1.0.5" @@ -3434,6 +3442,7 @@ node-schedule "^1.3.2" qs "^6.9.0" react "^16.12.0" + side-channel "^1.0.4" yargs "^14.0.0" "@eyeseetea/d2-ui-components@2.7.0": @@ -3757,6 +3766,48 @@ resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== +"@krakenjs/belter@^2.0.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@krakenjs/belter/-/belter-2.5.0.tgz#c2ead14df28b83b63d5a467e7d5960877f247210" + integrity sha512-c+vOJr5ojEPsH7s28XRLNkUamaXsnyM1Fs7PaYM9tUMnMUILnZ2a3jnOmXZwHYpT0eAciRHddX1333OlCR2e7Q== + dependencies: + "@krakenjs/cross-domain-safe-weakmap" "^2.0.2" + "@krakenjs/cross-domain-utils" "^3.0.2" + "@krakenjs/zalgo-promise" "^2.0.0" + +"@krakenjs/cross-domain-safe-weakmap@^2.0.0", "@krakenjs/cross-domain-safe-weakmap@^2.0.2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@krakenjs/cross-domain-safe-weakmap/-/cross-domain-safe-weakmap-2.0.3.tgz#eb607534c14bd8bc2f3456d993618361fb38489f" + integrity sha512-WsGi6347ddZ9Y0HoBuTYCX2QTAHxYVaUs2T/0n8XJKXZOEJPnLWlW6eYAOgyyuUsYusWMAkYv00fvfxAlTU8/w== + dependencies: + "@krakenjs/cross-domain-utils" "^3.0.2" + +"@krakenjs/cross-domain-utils@^3.0.0", "@krakenjs/cross-domain-utils@^3.0.2": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@krakenjs/cross-domain-utils/-/cross-domain-utils-3.1.0.tgz#c4863099acf36f05941345de1381ada1e207315c" + integrity sha512-if+mVTg56ZyLt8wn3ZbTw0IzNjbQvvj5X7lfcBlVfYdZ93TXgHa6Rt9nqdm1rZmvqb8Ct6L29Bp7SNC3xzLfUw== + +"@krakenjs/post-robot@^11.0.0": + version "11.0.0" + resolved "https://registry.yarnpkg.com/@krakenjs/post-robot/-/post-robot-11.0.0.tgz#98a285b70db2bac2c58f297cd8403a4cc2518a6a" + integrity sha512-t+IlQCrwzLa1IWxEvdfr9r/xgOmQoykVQ1rtEukw1LYVHPU3p3eDDC5djODE7ErWYDbkYncrHcbDEh6Gc/G9wQ== + dependencies: + "@krakenjs/belter" "^2.0.0" + "@krakenjs/cross-domain-safe-weakmap" "^2.0.0" + "@krakenjs/cross-domain-utils" "^3.0.0" + "@krakenjs/universal-serialize" "^2.0.0" + "@krakenjs/zalgo-promise" "^2.0.0" + +"@krakenjs/universal-serialize@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@krakenjs/universal-serialize/-/universal-serialize-2.0.0.tgz#c4a508ec53f6b412b1032e78c570b87f4f2c7763" + integrity sha512-Qd9W2iaP5lMTzXPETomBDAaqbgHYkEjJKcQ+3eNC8EwWGCHFk3/+uQ41B+QYfSZqRoz8AEjdNHOps7nDEypAyw== + +"@krakenjs/zalgo-promise@^2.0.0": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@krakenjs/zalgo-promise/-/zalgo-promise-2.0.1.tgz#36b4225a566f0a0903a8d771a11a9efc131c6987" + integrity sha512-n30eknZjD7z8/joFqjI8FIDZ0yJPZHcQBce1B3tAumwNZL0C42Ta/w37MfthxHV61JHEFGfy7b727h/kzagJDA== + "@material-ui/core@4.12.3": version "4.12.3" resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-4.12.3.tgz#80d665caf0f1f034e52355c5450c0e38b099d3ca" @@ -6032,6 +6083,15 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +belter@^1.0.41: + version "1.0.190" + resolved "https://registry.yarnpkg.com/belter/-/belter-1.0.190.tgz#491857550ef240d9c66b56fc637991f5c3089966" + integrity sha512-jz05FHrO+bwitdI6JxV5ESyRdVhTcwMWQ7L4o+q/R4LNJFQrG58sp9EiwsSjhbihhiyYFcmmCMRRagxte6igtw== + dependencies: + cross-domain-safe-weakmap "^1" + cross-domain-utils "^2" + zalgo-promise "^1" + bfj@^7.0.2: version "7.0.2" resolved "https://registry.yarnpkg.com/bfj/-/bfj-7.0.2.tgz#1988ce76f3add9ac2913fd8ba47aad9e651bfbb2" @@ -6597,6 +6657,14 @@ chalk@^4.0.0, chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@^4.0.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" @@ -6802,6 +6870,15 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + clone-buffer@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/clone-buffer/-/clone-buffer-1.0.0.tgz#e3e25b207ac4e701af721e2cb5a16792cac3dc58" @@ -7249,6 +7326,20 @@ cronstrue@^1.81.0: resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-1.110.0.tgz#cffbef09855141e518341ed5a4b575a5a4811638" integrity sha512-+ABuGZl/nqf/0TemAsPlMSGaB9xEobWhctfRGEqEvH3g6pB/2FGbsUHQPSL8Wt1W3nmOHe1w8GWjacN5k8d3hg== +cross-domain-safe-weakmap@^1, cross-domain-safe-weakmap@^1.0.1: + version "1.0.29" + resolved "https://registry.yarnpkg.com/cross-domain-safe-weakmap/-/cross-domain-safe-weakmap-1.0.29.tgz#0847975c27d9e1cc840f24c1745311958df98022" + integrity sha512-VLoUgf2SXnf3+na8NfeUFV59TRZkIJqCIATaMdbhccgtnTlSnHXkyTRwokngEGYdQXx8JbHT9GDYitgR2sdjuA== + dependencies: + cross-domain-utils "^2.0.0" + +cross-domain-utils@^2, cross-domain-utils@^2.0.0: + version "2.0.38" + resolved "https://registry.yarnpkg.com/cross-domain-utils/-/cross-domain-utils-2.0.38.tgz#2eaf321c4dfdb61596805ca4233fde4400cb6377" + integrity sha512-zZfi3+2EIR9l4chrEiXI2xFleyacsJf8YMLR1eJ0Veb5FTMXeJ3DpxDjZkto2FhL/g717WSELqbptNSo85UJDw== + dependencies: + zalgo-promise "^1.0.11" + cross-spawn@7.0.3, cross-spawn@^7.0.0, cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -8162,7 +8253,7 @@ duplexer2@~0.1.4: dependencies: readable-stream "^2.0.2" -duplexer@^0.1.1: +duplexer@^0.1.1, duplexer@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== @@ -8203,6 +8294,13 @@ ejs@^2.6.1: resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba" integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== +ejs@^3.1.5: + version "3.1.10" + resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.10.tgz#69ab8358b14e896f80cc39e62087b88500c3ac3b" + integrity sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA== + dependencies: + jake "^10.8.5" + electron-to-chromium@^1.3.564, electron-to-chromium@^1.3.649: version "1.3.693" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.693.tgz#5089c506a925c31f93fcb173a003a22e341115dd" @@ -8497,7 +8595,7 @@ escalade@^3.0.2, escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= @@ -9225,6 +9323,13 @@ file-uri-to-path@1.0.0: resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== +filelist@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5" + integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q== + dependencies: + minimatch "^5.0.1" + filename-regex@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" @@ -9621,7 +9726,7 @@ gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -get-caller-file@^2.0.1: +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -9868,6 +9973,13 @@ gzip-size@5.1.1: duplexer "^0.1.1" pify "^4.0.1" +gzip-size@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" + integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== + dependencies: + duplexer "^0.1.2" + handle-thing@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" @@ -11258,6 +11370,16 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +jake@^10.8.5: + version "10.9.2" + resolved "https://registry.yarnpkg.com/jake/-/jake-10.9.2.tgz#6ae487e6a69afec3a5e167628996b59f35ae2b7f" + integrity sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA== + dependencies: + async "^3.2.3" + chalk "^4.0.2" + filelist "^1.0.4" + minimatch "^3.1.2" + jest-changed-files@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-26.6.2.tgz#f6198479e1cc66f22f9ae1e22acaa0b429c042d0" @@ -12766,7 +12888,14 @@ minimatch@3.0.4, minimatch@^3.0.4: dependencies: brace-expansion "^1.1.7" -minimatch@^5.1.0: +minimatch@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimatch@^5.0.1, minimatch@^5.1.0: version "5.1.6" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== @@ -13364,7 +13493,7 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" -open@^7.0.2: +open@^7.0.2, open@^7.3.1: version "7.4.2" resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== @@ -13861,6 +13990,17 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +post-robot@^10.0.46: + version "10.0.46" + resolved "https://registry.yarnpkg.com/post-robot/-/post-robot-10.0.46.tgz#39cea5b51033729390fc7c90be3285cd285f0377" + integrity sha512-EgVJiuvI4iRWDZvzObWes0X/n8olWBEJWxlSw79zmhpgkigX8UsVL4VOBhVtoJKwf0Y9qP9g2zOONw1rv80QbA== + dependencies: + belter "^1.0.41" + cross-domain-safe-weakmap "^1.0.1" + cross-domain-utils "^2.0.0" + universal-serialize "^1.0.4" + zalgo-promise "^1.0.3" + postcss-attribute-case-insensitive@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/postcss-attribute-case-insensitive/-/postcss-attribute-case-insensitive-4.0.2.tgz#d93e46b504589e94ac7277b0463226c68041a880" @@ -15713,6 +15853,13 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +rimraf@~2.6.2: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + ripemd160@^2.0.0, ripemd160@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/ripemd160/-/ripemd160-2.0.2.tgz#a1c1a6f624751577ba5d07914cbc92850585890c" @@ -16255,6 +16402,24 @@ source-list-map@^2.0.0: resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34" integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw== +source-map-explorer@^2.5.3: + version "2.5.3" + resolved "https://registry.yarnpkg.com/source-map-explorer/-/source-map-explorer-2.5.3.tgz#33551b51e33b70f56d15e79083cdd4c43e583b69" + integrity sha512-qfUGs7UHsOBE5p/lGfQdaAj/5U/GWYBw2imEpD6UQNkqElYonkow8t+HBL1qqIl3CuGZx7n8/CQo4x1HwSHhsg== + dependencies: + btoa "^1.2.1" + chalk "^4.1.0" + convert-source-map "^1.7.0" + ejs "^3.1.5" + escape-html "^1.0.3" + glob "^7.1.6" + gzip-size "^6.0.0" + lodash "^4.17.20" + open "^7.3.1" + source-map "^0.7.4" + temp "^0.9.4" + yargs "^16.2.0" + source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: version "0.5.3" resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a" @@ -16301,6 +16466,11 @@ source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= +source-map@^0.7.4: + version "0.7.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" + integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== + sourcemap-codec@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" @@ -16869,6 +17039,14 @@ temp-dir@^1.0.0: resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" integrity sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0= +temp@^0.9.4: + version "0.9.4" + resolved "https://registry.yarnpkg.com/temp/-/temp-0.9.4.tgz#cd20a8580cb63635d0e4e9d4bd989d44286e7620" + integrity sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA== + dependencies: + mkdirp "^0.5.1" + rimraf "~2.6.2" + tempy@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.3.0.tgz#6f6c5b295695a16130996ad5ab01a8bd726e8bf8" @@ -17417,6 +17595,11 @@ unique-string@^1.0.0: dependencies: crypto-random-string "^1.0.0" +universal-serialize@^1.0.4: + version "1.0.10" + resolved "https://registry.yarnpkg.com/universal-serialize/-/universal-serialize-1.0.10.tgz#3279bb30f47290ea479f45135620f98fa9d3f3a6" + integrity sha512-FdouA4xSFa0fudk1+z5vLWtxZCoC0Q9lKYV3uUdFl7DttNfolmiw2ASr5ddY+/Yz6Isr68u3IqC9XMSwMP+Pow== + universalify@^0.1.0, universalify@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -18205,6 +18388,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -18270,6 +18462,11 @@ y18n@^4.0.0: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -18309,6 +18506,11 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + yargs@^13.3.2: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -18359,6 +18561,19 @@ yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" +yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + yauzl@^2.10.0: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" @@ -18377,6 +18592,11 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +zalgo-promise@^1, zalgo-promise@^1.0.11, zalgo-promise@^1.0.3: + version "1.0.48" + resolved "https://registry.yarnpkg.com/zalgo-promise/-/zalgo-promise-1.0.48.tgz#9e33eef502d5ed9f5a09fc5728c833c3e87afa2e" + integrity sha512-LLHANmdm53+MucY9aOFIggzYtUdkSBFxUsy4glTTQYNyK6B3uCPWTbfiGvSrEvLojw0mSzyFJ1/RRLv+QMNdzQ== + zip-stream@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-4.1.0.tgz#51dd326571544e36aa3f756430b313576dc8fc79"