From deb23e443c8b48770fc92f00e9c98bffacc408f1 Mon Sep 17 00:00:00 2001 From: svwolter Date: Mon, 4 Aug 2025 14:24:51 +0200 Subject: [PATCH 01/12] [CI/CD] Add 'dockerd' startup check for 'dind' jobs --- .../Branch&PreRelease-Pipelines.gitlab-ci.yml | 42 +++++++++ .gitlab-ci/Release-Pipelines.gitlab-ci.yml | 94 +++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml b/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml index e4f2bcda7..985a8039f 100644 --- a/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml +++ b/.gitlab-ci/Branch&PreRelease-Pipelines.gitlab-ci.yml @@ -238,6 +238,8 @@ build-cicd-base-image: entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -276,6 +278,8 @@ build-cicd-base-image: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - ip a | grep mtu # - docker network inspect bridge | grep mtu # - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -322,6 +326,8 @@ build-db-image: variables: DB_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -357,6 +363,8 @@ build-liquibase-image: variables: LIQUIBASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-liquibase" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -406,6 +414,10 @@ test-db: entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY @@ -509,6 +521,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -553,6 +567,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -590,6 +606,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -627,6 +645,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -665,6 +685,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -702,6 +724,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -740,6 +764,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -777,6 +803,8 @@ test-app: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - docker network create app-net # - echo "$REGISTRY_PASSWORD" | docker login -u $REGISTRY_USER --password-stdin $REGISTRY # - docker pull --quiet ${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db:${CI_COMMIT_SHA} @@ -836,6 +864,8 @@ build-develop-commit-db-image: variables: DB_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -870,6 +900,8 @@ build-develop-commit-liquibase-image: variables: LIQUIBASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-liquibase" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -904,6 +936,8 @@ build-develop-commit-base-image: variables: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -938,6 +972,8 @@ build-develop-commit-backend-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -978,6 +1014,8 @@ build-develop-commit-frontend-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -1250,6 +1288,10 @@ build-pre-release: BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - echo "$DOCKERHUB_PASSWORD" | docker login -u $DOCKERHUB_USER --password-stdin - docker pull -q ${DB_IMAGE_NAME}:${CI_COMMIT_SHA} diff --git a/.gitlab-ci/Release-Pipelines.gitlab-ci.yml b/.gitlab-ci/Release-Pipelines.gitlab-ci.yml index 52e73d872..daee9c42d 100644 --- a/.gitlab-ci/Release-Pipelines.gitlab-ci.yml +++ b/.gitlab-ci/Release-Pipelines.gitlab-ci.yml @@ -117,6 +117,8 @@ build-main-pr-base-image: variables: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -152,6 +154,8 @@ build-main-pr-backend-test-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -192,6 +196,8 @@ build-main-pr-frontend-test-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -234,6 +240,8 @@ build-main-pr-frontend-test-image: # BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" # FRONTEND_E2E_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend-e2e" # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done # - ip a | grep mtu # - docker network inspect bridge | grep mtu # - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -270,6 +278,8 @@ build-main-pr-db-image: variables: DB_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -305,6 +315,8 @@ build-main-pr-liquibase-image: variables: LIQUIBASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-liquibase" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -344,6 +356,8 @@ build-main-pr-backend-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY @@ -386,6 +400,8 @@ build-main-pr-frontend-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY @@ -425,6 +441,10 @@ test-main-pr-db: entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] alias: docker before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY @@ -522,6 +542,10 @@ test-main-pr-backend: variables: BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - docker pull -q ${BACKEND_IMAGE_NAME}:${CI_COMMIT_SHA}_test script: @@ -547,6 +571,10 @@ test-main-pr-frontend: variables: FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - docker pull -q ${FRONTEND_IMAGE_NAME}:${CI_COMMIT_SHA}_test script: @@ -573,6 +601,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -617,6 +649,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -660,6 +696,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -704,6 +744,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -749,6 +793,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -793,6 +841,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -838,6 +890,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -882,6 +938,10 @@ test-main-pr-frontend: # entrypoint: [ "sh", "-c", "dockerd-entrypoint.sh $DOCKER_DAEMON_OPTIONS" ] # alias: docker # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - sed -i "s/TAG=.*$/TAG=${CI_COMMIT_SHA}/" .env.coding-box.template # - sed -i "s^REGISTRY_PATH=.*$^REGISTRY_PATH=${CI_REGISTRY_IMAGE}/^" .env.coding-box.template # - export $(grep -v '^#' .env.coding-box.template | xargs) @@ -926,6 +986,10 @@ lint-main-pr-backend: variables: BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - docker pull -q ${BACKEND_IMAGE_NAME}:${CI_COMMIT_SHA}_test script: @@ -951,6 +1015,10 @@ lint-main-pr-frontend: variables: FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - docker pull -q ${FRONTEND_IMAGE_NAME}:${CI_COMMIT_SHA}_test script: @@ -976,6 +1044,10 @@ lint-main-pr-frontend: # variables: # FRONTEND_E2E_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend-e2e" # before_script: +# - docker --version +# - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done +# - ip a | grep mtu +# - docker network inspect bridge | grep mtu # - docker pull -q ${FRONTEND_E2E_IMAGE_NAME}:${CI_COMMIT_SHA} # script: # - docker run ${FRONTEND_E2E_IMAGE_NAME}:${CI_COMMIT_SHA} lint frontend-e2e @@ -997,6 +1069,10 @@ audit-main-pr-backend: variables: BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - docker pull -q ${BACKEND_IMAGE_NAME}:${CI_COMMIT_SHA}_test script: @@ -1021,6 +1097,10 @@ audit-main-pr-frontend: variables: FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - docker pull -q ${FRONTEND_IMAGE_NAME}:${CI_COMMIT_SHA}_test script: @@ -1042,6 +1122,8 @@ build-main-commit-db-image: variables: DB_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-db" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -1075,6 +1157,8 @@ build-main-commit-liquibase-image: variables: LIQUIBASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-liquibase" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -1108,6 +1192,8 @@ build-main-commit-base-image: variables: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -1144,6 +1230,8 @@ build-main-commit-backend-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -1183,6 +1271,8 @@ build-main-commit-frontend-image: BASE_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-base" FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done - ip a | grep mtu - docker network inspect bridge | grep mtu - docker login -u $CI_DEPENDENCY_PROXY_USER -p $CI_DEPENDENCY_PROXY_PASSWORD $CI_DEPENDENCY_PROXY_SERVER @@ -1454,6 +1544,10 @@ build-release: BACKEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-backend" FRONTEND_IMAGE_NAME: "${CI_REGISTRY_IMAGE}/iqbberlin/coding-box-frontend" before_script: + - docker --version + - until docker info &> /dev/null; do println "Wait until docker daemon is started ...\n" && sleep 1; done + - ip a | grep mtu + - docker network inspect bridge | grep mtu - echo "$CI_REGISTRY_PASSWORD" | docker login -u $CI_REGISTRY_USER --password-stdin $CI_REGISTRY - echo "$DOCKERHUB_PASSWORD" | docker login -u $DOCKERHUB_USER --password-stdin - docker pull -q ${DB_IMAGE_NAME}:${CI_COMMIT_SHA} From 7c5a63abd2b77724c6f3c18a3bdb711ae1f47184 Mon Sep 17 00:00:00 2001 From: svwolter Date: Tue, 5 Aug 2025 16:58:18 +0200 Subject: [PATCH 02/12] [CI/CD] Upgrade docker to version 28 --- .gitlab-ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 87e7d4912..ca0768418 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,8 +34,8 @@ variables: # E2E_BASE_IMAGE: "${CYPRESS_IMAGE}-docker" DOCKER_HOST: tcp://docker:2375 DOCKER_DAEMON_OPTIONS: "--mtu=${DOCKER_SERVICE_MTU}" - DOCKER_IMAGE: ${DOCKER_HUB_PROXY}docker:27.0 - DOCKER_SERVICE: ${DOCKER_HUB_PROXY}docker:27.0-dind + DOCKER_IMAGE: ${DOCKER_HUB_PROXY}docker:28 + DOCKER_SERVICE: ${DOCKER_HUB_PROXY}docker:28-dind DOCKER_SERVICE_MTU: 1392 DOCKER_TLS_CERTDIR: '' TRIVY_IMAGE: ${DOCKER_HUB_PROXY}aquasec/trivy:latest From d14c5ab057afd31c5abbeaafe10041ab767feb50 Mon Sep 17 00:00:00 2001 From: svwolter Date: Tue, 5 Aug 2025 17:12:27 +0200 Subject: [PATCH 03/12] Synchronize 'package.json' and 'package-lock.json' --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/package-lock.json b/package-lock.json index ffdd755de..64c773713 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7569,6 +7569,19 @@ "ioredis": ">=5.0.0" } }, + "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/axios": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", + "integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==", + "license": "MIT", + "optional": true, + "peer": true, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", + "axios": "^1.3.1", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/terminus": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", @@ -7640,6 +7653,32 @@ } } }, + "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/typeorm": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", + "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0", + "rxjs": "^7.2.0", + "typeorm": "^0.3.0" + } + }, + "node_modules/@nestjs-modules/ioredis/node_modules/reflect-metadata": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", + "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", + "license": "Apache-2.0", + "optional": true, + "peer": true + }, "node_modules/@nestjs/axios": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", From fee2927c8ea2651a107faa962505f0be9553e8f9 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 12:43:48 +0200 Subject: [PATCH 04/12] Search for a booklet in the test result component --- .../workspace-test-results.controller.ts | 102 +++ .../workspace-test-results.service.ts | 80 ++ .../src/app/services/backend.service.ts | 34 + .../src/app/services/test-result.service.ts | 87 +++ .../booklet-search-dialog.component.html | 90 +++ .../booklet-search-dialog.component.scss | 79 ++ .../booklet-search-dialog.component.ts | 271 +++++++ .../test-results-search.component.html | 399 ++++++++++ .../test-results-search.component.scss | 169 +++++ .../test-results-search.component.ts | 715 ++++++++++++++++++ .../test-results/test-results.component.html | 2 +- .../test-results/test-results.component.ts | 8 +- .../unit-search-dialog.component.html | 88 ++- .../unit-search-dialog.component.ts | 280 ++++++- 14 files changed, 2374 insertions(+), 30 deletions(-) create mode 100644 apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.html create mode 100644 apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.scss create mode 100644 apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts create mode 100644 apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.html create mode 100644 apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.scss create mode 100644 apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.ts diff --git a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts index c9b1cf739..4d5363148 100644 --- a/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-test-results.controller.ts @@ -533,6 +533,108 @@ export class WorkspaceTestResultsController { } } + @Get(':workspace_id/booklets/search') + @ApiOperation({ + summary: 'Search for booklets by name', + description: 'Searches for booklets with a specific name across all test persons in a workspace' + }) + @ApiParam({ name: 'workspace_id', type: Number, description: 'ID of the workspace' }) + @ApiQuery({ + name: 'bookletName', + required: true, + description: 'Name of the booklet to search for', + type: String + }) + @ApiQuery({ + name: 'page', + required: false, + description: 'Page number (1-based)', + type: Number + }) + @ApiQuery({ + name: 'limit', + required: false, + description: 'Number of items per page', + type: Number + }) + @ApiOkResponse({ + description: 'Booklets retrieved successfully.', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { + type: 'object', + properties: { + bookletId: { type: 'number', description: 'ID of the booklet' }, + bookletName: { type: 'string', description: 'Name of the booklet' }, + personId: { type: 'number', description: 'ID of the person' }, + personLogin: { type: 'string', description: 'Login of the person' }, + personCode: { type: 'string', description: 'Code of the person' }, + personGroup: { type: 'string', description: 'Group of the person' }, + units: { + type: 'array', + description: 'Units in the booklet', + items: { + type: 'object', + properties: { + unitId: { type: 'number', description: 'ID of the unit' }, + unitName: { type: 'string', description: 'Name of the unit' }, + unitAlias: { type: 'string', nullable: true, description: 'Alias of the unit' } + } + } + } + } + } + }, + total: { type: 'number', description: 'Total number of items' } + } + } + }) + @ApiBadRequestResponse({ description: 'Failed to search for booklets' }) + @UseGuards(JwtAuthGuard, WorkspaceGuard) + async findBookletsByName( + @Param('workspace_id') workspace_id: number, + @Query('bookletName') bookletName: string, + @Query('page') page?: number, + @Query('limit') limit?: number + ): Promise<{ + data: { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; + }[]; + total: number; + }> { + if (!workspace_id || Number.isNaN(workspace_id)) { + throw new BadRequestException('Invalid workspace_id.'); + } + + if (!bookletName) { + throw new BadRequestException('Booklet name is required.'); + } + + try { + return await this.workspaceTestResultsService.findBookletsByName( + workspace_id, + bookletName, + { page, limit } + ); + } catch (error) { + logger.error(`Error searching for booklets: ${error}`); + throw new BadRequestException(`Failed to search for booklets. ${error.message}`); + } + } + @Get(':workspace_id/units/search') @ApiOperation({ summary: 'Search for units by name', diff --git a/apps/backend/src/app/database/services/workspace-test-results.service.ts b/apps/backend/src/app/database/services/workspace-test-results.service.ts index 8c512c3aa..b70a7dff2 100644 --- a/apps/backend/src/app/database/services/workspace-test-results.service.ts +++ b/apps/backend/src/app/database/services/workspace-test-results.service.ts @@ -991,4 +991,84 @@ export class WorkspaceTestResultsService { throw new Error(`An error occurred while searching for units with name: ${unitName}: ${error.message}`); } } + + async findBookletsByName( + workspaceId: number, + bookletName: string, + options: { page?: number; limit?: number } = {} + ): Promise<{ + data: { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; + }[]; + total: number; + }> { + if (!workspaceId || !bookletName) { + throw new Error('Both workspaceId and bookletName are required.'); + } + + const page = options.page || 1; + const limit = options.limit || 10; + const skip = (page - 1) * limit; + + this.logger.log(`Finding booklets by name for workspace ${workspaceId}, bookletName: ${bookletName}`); + + try { + this.logger.log( + `Searching for booklets with name: ${bookletName} in workspace: ${workspaceId} (page: ${page}, limit: ${limit})` + ); + + // Create a query to find all booklets with the given name + const query = this.bookletRepository.createQueryBuilder('booklet') + .innerJoinAndSelect('booklet.person', 'person') + .innerJoinAndSelect('booklet.bookletinfo', 'bookletinfo') + .leftJoinAndSelect('booklet.units', 'unit') + .where('bookletinfo.name ILIKE :bookletName', { bookletName: `%${bookletName}%` }) + .andWhere('person.workspace_id = :workspaceId', { workspaceId }); + + const total = await query.getCount(); + + if (total === 0) { + this.logger.log(`No booklets found with name: ${bookletName} in workspace: ${workspaceId}`); + return { data: [], total: 0 }; + } + + query.skip(skip).take(limit); + + const booklets = await query.getMany(); + + this.logger.log(`Found ${total} booklets with name: ${bookletName} in workspace: ${workspaceId}, returning ${booklets.length} for page ${page}`); + + const data = booklets.map(booklet => ({ + bookletId: booklet.id, + bookletName: booklet.bookletinfo.name, + personId: booklet.person.id, + personLogin: booklet.person.login, + personCode: booklet.person.code, + personGroup: booklet.person.group, + units: booklet.units ? booklet.units.map(unit => ({ + unitId: unit.id, + unitName: unit.name, + unitAlias: unit.alias + })) : [] + })); + + return { data, total }; + } catch (error) { + this.logger.error( + `Failed to search for booklets with name: ${bookletName} in workspace: ${workspaceId}`, + error.stack + ); + throw new Error(`An error occurred while searching for booklets with name: ${bookletName}: ${error.message}`); + } + } } diff --git a/apps/frontend/src/app/services/backend.service.ts b/apps/frontend/src/app/services/backend.service.ts index 76b639938..be7f6f887 100755 --- a/apps/frontend/src/app/services/backend.service.ts +++ b/apps/frontend/src/app/services/backend.service.ts @@ -500,6 +500,30 @@ export class BackendService { return this.responseService.searchResponses(workspaceId, searchParams, page, limit); } + searchBookletsByName( + workspaceId: number, + bookletName: string, + page?: number, + limit?: number + ): Observable<{ + data: { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; + }[]; + total: number; + }> { + return this.testResultService.searchBookletsByName(workspaceId, bookletName, page, limit); + } + searchUnitsByName( workspaceId: number, unitName: string, @@ -564,6 +588,16 @@ export class BackendService { return this.responseService.deleteMultipleResponses(workspaceId, responseIds); } + deleteBooklet(workspaceId: number, bookletId: number): Observable<{ + success: boolean; + report: { + deletedBooklet: number | null; + warnings: string[]; + }; + }> { + return this.testResultService.deleteBooklet(workspaceId, bookletId); + } + validateVariables(workspaceId: number, page: number = 1, limit: number = 10): Observable> { return this.validationService.validateVariables(workspaceId, page, limit); } diff --git a/apps/frontend/src/app/services/test-result.service.ts b/apps/frontend/src/app/services/test-result.service.ts index 09bf7fbf0..61928dcc2 100644 --- a/apps/frontend/src/app/services/test-result.service.ts +++ b/apps/frontend/src/app/services/test-result.service.ts @@ -50,6 +50,63 @@ export class TestResultService { this.cacheService.invalidateWorkspaceCache(workspaceId); } + searchBookletsByName( + workspaceId: number, + bookletName: string, + page?: number, + limit?: number + ): Observable<{ + data: { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; + }[]; + total: number; + }> { + let params = new HttpParams().set('bookletName', bookletName); + + if (page !== undefined) { + params = params.set('page', page.toString()); + } + + if (limit !== undefined) { + params = params.set('limit', limit.toString()); + } + + return this.http.get<{ + data: { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; + }[]; + total: number; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/booklets/search`, + { headers: this.authHeader, params } + ).pipe( + catchError(() => { + logger.error(`Error searching for booklets with name: ${bookletName}`); + return of({ data: [], total: 0 }); + }) + ); + } + searchUnitsByName( workspaceId: number, unitName: string, @@ -185,4 +242,34 @@ export class TestResultService { }) ); } + + /** + * Delete a booklet and all its associated units and responses + * @param workspaceId The ID of the workspace + * @param bookletId The ID of the booklet to delete + * @returns An Observable of the deletion result + */ + deleteBooklet(workspaceId: number, bookletId: number): Observable<{ + success: boolean; + report: { + deletedBooklet: number | null; + warnings: string[]; + }; + }> { + return this.http.delete<{ + success: boolean; + report: { + deletedBooklet: number | null; + warnings: string[]; + }; + }>( + `${this.serverUrl}admin/workspace/${workspaceId}/booklets/${bookletId}`, + { headers: this.authHeader } + ).pipe( + catchError(() => { + logger.error(`Error deleting booklet with ID: ${bookletId}`); + return of({ success: false, report: { deletedBooklet: null, warnings: ['Failed to delete booklet'] } }); + }) + ); + } } diff --git a/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.html b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.html new file mode 100644 index 000000000..5a5185134 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.html @@ -0,0 +1,90 @@ +
+

Booklet Suche

+
+
+ + Booklet Name + + search + +
+ +
+
+ +

Suche läuft...

+
+ +
+

Keine Booklets gefunden.

+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Booklet Name{{ booklet.bookletName }}Code{{ booklet.personCode }}Login{{ booklet.personLogin }}Gruppe{{ booklet.personGroup }}Units{{ booklet.units.length }}Aktionen + + +
+ + + +
+
+
+
+ +
+
diff --git a/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.scss b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.scss new file mode 100644 index 000000000..4590388a0 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.scss @@ -0,0 +1,79 @@ +.dialog-container { + min-width: 800px; + max-width: 1200px; + padding: 20px; +} + +.search-container { + margin-bottom: 20px; +} + +.search-field { + width: 100%; +} + +.results-container { + min-height: 300px; + max-height: 600px; + overflow: auto; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; +} + +.no-results { + display: flex; + justify-content: center; + align-items: center; + height: 300px; + font-size: 16px; + color: #666; +} + +.results-table-container { + display: flex; + flex-direction: column; +} + +.table-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 10px; +} + +.results-table { + width: 100%; + margin-bottom: 20px; +} + +th.mat-header-cell { + font-weight: bold; + color: rgba(0, 0, 0, 0.87); +} + +.mat-column-bookletName { + min-width: 150px; + max-width: 250px; +} + +.mat-column-personCode, +.mat-column-personLogin, +.mat-column-personGroup { + min-width: 100px; + max-width: 150px; +} + +.mat-column-unitCount { + width: 80px; + text-align: center; +} + +.mat-column-actions { + width: 100px; + text-align: center; +} diff --git a/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts new file mode 100644 index 000000000..93ac5543f --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts @@ -0,0 +1,271 @@ +import { + Component, Inject, OnInit, ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, + MAT_DIALOG_DATA +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTableModule } from '@angular/material/table'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; +import { BookletInfoDialogComponent } from '../booklet-info-dialog/booklet-info-dialog.component'; + +interface BookletSearchResult { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; +} + +@Component({ + selector: 'coding-box-booklet-search-dialog', + templateUrl: './booklet-search-dialog.component.html', + styleUrls: ['./booklet-search-dialog.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatDialogModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + MatIconModule, + MatTableModule, + MatProgressSpinnerModule, + MatPaginatorModule, + MatTooltipModule, + TranslateModule + ] +}) +export class BookletSearchDialogComponent implements OnInit { + @ViewChild(MatPaginator) paginator!: MatPaginator; + + bookletSearchText = ''; + bookletSearchResults: BookletSearchResult[] = []; + isLoading = false; + totalResults = 0; + currentPage = 1; + pageSize = 10; + displayedColumns: string[] = ['bookletName', 'personCode', 'personLogin', 'personGroup', 'unitCount', 'actions']; + private searchSubject = new Subject(); + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { initialSearch?: string }, + private backendService: BackendService, + private appService: AppService, + private router: Router, + private dialog: MatDialog, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + // Set up debounced search + this.searchSubject.pipe( + debounceTime(500), + distinctUntilChanged() + ).subscribe(searchText => { + this.searchBooklets(searchText); + }); + + // Initial search if data is provided + if (this.data && this.data.initialSearch) { + this.bookletSearchText = this.data.initialSearch; + this.searchBooklets(this.bookletSearchText); + } + } + + onBookletSearchChange(): void { + this.isLoading = true; + this.searchSubject.next(this.bookletSearchText); + } + + onPageChange(event: PageEvent): void { + this.currentPage = event.pageIndex + 1; + this.pageSize = event.pageSize; + this.searchBooklets(this.bookletSearchText); + } + + searchBooklets(bookletName: string): void { + if (!bookletName || bookletName.trim() === '') { + this.bookletSearchResults = []; + this.totalResults = 0; + this.isLoading = false; + return; + } + + this.isLoading = true; + this.backendService.searchBookletsByName( + this.appService.selectedWorkspaceId, + bookletName, + this.currentPage, + this.pageSize + ).subscribe({ + next: (response) => { + this.bookletSearchResults = response.data; + this.totalResults = response.total; + this.isLoading = false; + }, + error: () => { + this.bookletSearchResults = []; + this.totalResults = 0; + this.isLoading = false; + } + }); + } + + close(): void { + this.dialogRef.close(); + } + + viewBookletInfo(booklet: BookletSearchResult): void { + this.dialog.open(BookletInfoDialogComponent, { + width: '800px', + data: { + bookletId: booklet.bookletId, + bookletName: booklet.bookletName + } + }); + } + + deleteBooklet(booklet: BookletSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Booklet löschen', + content: `Sind Sie sicher, dass Sie das Booklet "${booklet.bookletName}" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + this.isLoading = true; + this.backendService.deleteBooklet( + this.appService.selectedWorkspaceId, + booklet.bookletId + ).subscribe({ + next: (response) => { + if (response.success) { + // Remove the deleted booklet from the results + this.bookletSearchResults = this.bookletSearchResults.filter( + b => b.bookletId !== booklet.bookletId + ); + + this.snackBar.open( + `Booklet "${booklet.bookletName}" wurde erfolgreich gelöscht.`, + 'OK', + { duration: 3000 } + ); + } else { + this.snackBar.open( + `Fehler beim Löschen des Booklets: ${response.report.warnings.join(', ')}`, + 'OK', + { duration: 5000 } + ); + } + this.isLoading = false; + }, + error: () => { + this.snackBar.open( + 'Fehler beim Löschen des Booklets. Bitte versuchen Sie es erneut.', + 'OK', + { duration: 5000 } + ); + this.isLoading = false; + } + }); + } + }); + } + + deleteAllBooklets(): void { + if (this.bookletSearchResults.length === 0) { + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle Booklets löschen', + content: `Sind Sie sicher, dass Sie alle ${this.bookletSearchResults.length} gefundenen Booklets löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result) { + const bookletIds = this.bookletSearchResults.map(booklet => booklet.bookletId); + let successCount = 0; + let failCount = 0; + let processedCount = 0; + + this.isLoading = true; + + // Process each booklet deletion sequentially + const processNextBooklet = (index: number) => { + if (index >= bookletIds.length) { + // All booklets processed + this.isLoading = false; + this.snackBar.open( + `${successCount} Booklets gelöscht, ${failCount} fehlgeschlagen.`, + 'OK', + { duration: 5000 } + ); + // Refresh the search results + this.searchBooklets(this.bookletSearchText); + return; + } + + this.backendService.deleteBooklet( + this.appService.selectedWorkspaceId, + bookletIds[index] + ).subscribe({ + next: (response) => { + if (response.success) { + successCount++; + } else { + failCount++; + } + processedCount++; + processNextBooklet(index + 1); + }, + error: () => { + failCount++; + processedCount++; + processNextBooklet(index + 1); + } + }); + }; + + // Start processing + processNextBooklet(0); + } + }); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.html b/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.html new file mode 100644 index 000000000..51976770c --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.html @@ -0,0 +1,399 @@ +
+

{{ data.title }}

+
+ +
+ + + +
+ + + @if (searchMode === 'unit') { +
+ + Aufgabe suchen + + search + +
+ } + + + @if (searchMode === 'response') { + + } + + + @if (searchMode === 'booklet') { +
+ + Booklet suchen + + search + +
+ } + +
+ @if (isLoading) { +
+ +

Suche läuft...

+
+ } @else if (searchMode === 'unit' && unitSearchResults.length === 0 && searchText.trim().length > 2) { +
+ search_off +

Keine Ergebnisse gefunden für "{{ searchText }}"

+
+ } @else if (searchMode === 'response' && responseSearchResults.length === 0 && + (searchValue.trim() !== '' || searchVariableId.trim() !== '' || searchUnitName.trim() !== '' || + searchStatus.trim() !== '' || searchCodedStatus.trim() !== '' || searchGroup.trim() !== '' || + searchCode.trim() !== '')) { +
+ search_off +

Keine Ergebnisse gefunden für die angegebenen Suchkriterien

+
+ } @else if (searchMode === 'booklet' && bookletSearchResults.length === 0 && bookletSearchText.trim().length > 2) { +
+ search_off +

Keine Ergebnisse gefunden für "{{ bookletSearchText }}"

+
+ } @else if (searchMode === 'unit' && searchText.trim().length <= 2) { +
+ info +

Geben Sie mindestens 3 Zeichen ein, um die Suche zu starten

+
+ } @else if (searchMode === 'response' && + searchValue.trim() === '' && + searchVariableId.trim() === '' && + searchUnitName.trim() === '' && + searchStatus.trim() === '' && + searchCodedStatus.trim() === '' && + searchGroup.trim() === '' && + searchCode.trim() === '') { +
+ info +

Geben Sie Zeichen in eines der Suchfelder ein, um die Suche zu starten

+
+ } @else if (searchMode === 'booklet' && bookletSearchText.trim().length <= 2) { +
+ info +

Geben Sie mindestens 3 Zeichen ein, um die Suche zu starten

+
+ } @else { + + @if (searchMode === 'unit') { + + @if (unitSearchResults.length > 0) { +
+ +
+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aufgabe{{ unit.unitName }}Alias{{ unit.unitAlias || '-' }}Booklet{{ unit.bookletName }}Login{{ unit.personLogin }}Code{{ unit.personCode }}Gruppe{{ unit.personGroup }}Tags +
+ @for (tag of unit.tags; track tag) { + + {{ tag.tag }} + + } + @if (unit.tags.length === 0) { + Keine Tags + } +
+
Antwort + @if (unit.responses && unit.responses.length > 0) { +
+ @for (response of unit.responses.slice(0, 1); track response) { +
+ {{ response.variableId }}: + {{ response.value | slice:0:50 }}{{ response.value.length > 50 ? '...' : '' }} +
+ } + @if (unit.responses.length > 1) { + +{{ unit.responses.length - 1 }} weitere + } +
+ } @else { + Keine Antwort verfügbar + } +
Aktionen + + +
+ } + + + @if (searchMode === 'response') { + + @if (responseSearchResults.length > 0) { +
+ +
+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Variable ID{{ response.variableId }}Wert + {{ response.value | slice:0:50 }}{{ response.value.length > 50 ? '...' : '' }} + Status{{ response.status }}Kodier Status{{ response.codedStatus }}Aufgabe{{ response.unitName }}Alias{{ response.unitAlias || '-' }}Booklet{{ response.bookletName }}Login{{ response.personLogin }}Code{{ response.personCode }}Gruppe{{ response.personGroup }}Aktionen + + +
+ } + + + @if (searchMode === 'booklet') { + + @if (bookletSearchResults.length > 0) { +
+ +
+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Booklet Name{{ booklet.bookletName }}Code{{ booklet.personCode }}Login{{ booklet.personLogin }}Gruppe{{ booklet.personGroup }}Units{{ booklet.units.length }}Aktionen + + +
+ } + + + + + } +
+
+
+ +
+
diff --git a/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.scss b/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.scss new file mode 100644 index 000000000..f9e0b4935 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.scss @@ -0,0 +1,169 @@ +.test-results-search-dialog { + min-width: 800px; + max-width: 1200px; + height: 80vh; + display: flex; + flex-direction: column; +} + +.mat-mdc-dialog-content { + max-height: none; + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +.search-mode-toggle { + display: flex; + margin-bottom: 16px; + border-bottom: 1px solid #e0e0e0; + + button { + flex: 1; + border-radius: 0; + padding: 12px; + font-weight: normal; + color: rgba(0, 0, 0, 0.6); + position: relative; + + &.active { + color: #3f51b5; + font-weight: 500; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background-color: #3f51b5; + } + } + + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } +} + +.search-container { + margin-bottom: 16px; + + &.response-search { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 16px; + } + + .search-field { + width: 100%; + } +} + +.results-container { + flex: 1; + overflow: auto; + position: relative; + margin-bottom: 16px; +} + +.loading-container, .no-results, .search-hint { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 200px; + color: rgba(0, 0, 0, 0.6); + text-align: center; + + mat-icon { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 16px; + opacity: 0.6; + } + + p { + font-size: 16px; + max-width: 400px; + } +} + +.delete-all-container { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} + +.results-table { + width: 100%; + border: 1px solid #e0e0e0; + border-radius: 4px; + overflow: hidden; + + th { + font-weight: 500; + color: rgba(0, 0, 0, 0.87); + background-color: #f5f5f5; + } + + .mat-mdc-row { + &:hover { + background-color: rgba(0, 0, 0, 0.04); + } + } +} + +.tags-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + + .tag-chip { + padding: 2px 8px; + border-radius: 16px; + font-size: 12px; + white-space: nowrap; + } + + .no-tags { + color: rgba(0, 0, 0, 0.38); + font-style: italic; + } +} + +.response-value { + .response-content { + display: flex; + align-items: flex-start; + gap: 4px; + } + + .response-variable { + font-weight: 500; + white-space: nowrap; + } + + .response-text { + word-break: break-word; + } + + .more-responses { + display: block; + margin-top: 4px; + font-size: 12px; + color: rgba(0, 0, 0, 0.6); + } +} + +.no-response { + color: rgba(0, 0, 0, 0.38); + font-style: italic; +} + +.result-row { + cursor: pointer; +} diff --git a/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.ts b/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.ts new file mode 100644 index 000000000..0164eb7b9 --- /dev/null +++ b/apps/frontend/src/app/ws-admin/components/test-results-search/test-results-search.component.ts @@ -0,0 +1,715 @@ +import { + Component, Inject, OnInit, ViewChild +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDialog, + MatDialogModule, + MatDialogRef, + MAT_DIALOG_DATA +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTableModule } from '@angular/material/table'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { Subject, debounceTime, distinctUntilChanged } from 'rxjs'; +import { BackendService } from '../../../services/backend.service'; +import { AppService } from '../../../services/app.service'; +import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; +import { BookletInfoDialogComponent } from '../booklet-info-dialog/booklet-info-dialog.component'; +import { BookletInfoDto } from '../../../../../../../api-dto/booklet-info/booklet-info.dto'; + +interface UnitSearchResult { + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + tags: { id: number; unitId: number; tag: string; color?: string; createdAt: Date }[]; + responses: { variableId: string; value: string; status: string; code?: number; score?: number; codedStatus?: string }[]; +} + +interface ResponseSearchResult { + responseId: number; + variableId: string; + value: string; + status: string; + code?: number; + score?: number; + codedStatus?: string; + unitId: number; + unitName: string; + unitAlias: string | null; + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; +} + +interface BookletSearchResult { + bookletId: number; + bookletName: string; + personId: number; + personLogin: string; + personCode: string; + personGroup: string; + units: { + unitId: number; + unitName: string; + unitAlias: string | null; + }[]; +} + +@Component({ + selector: 'coding-box-test-results-search', + templateUrl: './test-results-search.component.html', + styleUrls: ['./test-results-search.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTableModule, + MatProgressSpinnerModule, + MatTooltipModule, + MatPaginatorModule, + TranslateModule + ] +}) +export class TestResultsSearchComponent implements OnInit { + searchText: string = ''; + searchValue: string = ''; + searchVariableId: string = ''; + searchUnitName: string = ''; + searchStatus: string = ''; + searchCodedStatus: string = ''; + searchGroup: string = ''; + searchCode: string = ''; + + searchMode: 'unit' | 'response' | 'booklet' = 'unit'; + + unitSearchResults: UnitSearchResult[] = []; + responseSearchResults: ResponseSearchResult[] = []; + bookletSearchResults: BookletSearchResult[] = []; + bookletSearchText: string = ''; + + isLoading: boolean = false; + unitDisplayedColumns: string[] = ['unitName', 'unitAlias', 'bookletName', 'personLogin', 'personCode', 'personGroup', 'tags', 'responseValue', 'actions']; + responseDisplayedColumns: string[] = ['variableId', 'value', 'status', 'codedStatus', 'unitName', 'unitAlias', 'bookletName', 'personLogin', 'personCode', 'personGroup', 'actions']; + bookletDisplayedColumns: string[] = ['bookletName', 'personCode', 'personLogin', 'personGroup', 'unitCount', 'actions']; + + private unitSearchSubject = new Subject(); + private responseSearchSubject = new Subject<{ value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }>(); + private bookletSearchSubject = new Subject(); + private readonly SEARCH_DEBOUNCE_TIME = 500; + + totalItems: number = 0; + pageSize: number = 10; + pageIndex: number = 0; + pageSizeOptions: number[] = [50, 100, 200, 500]; + + @ViewChild(MatPaginator) paginator!: MatPaginator; + + constructor( + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { title: string }, + private backendService: BackendService, + private appService: AppService, + private router: Router, + private dialog: MatDialog, + private snackBar: MatSnackBar + ) {} + + ngOnInit(): void { + this.unitSearchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(searchText => { + this.pageIndex = 0; // Reset to first page on new search + this.searchUnits(searchText); + }); + + this.responseSearchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged((prev, curr) => prev.value === curr.value && prev.variableId === curr.variableId && prev.unitName === curr.unitName && prev.status === curr.status && prev.codedStatus === curr.codedStatus && prev.group === curr.group && prev.code === curr.code) + ).subscribe(searchParams => { + this.pageIndex = 0; // Reset to first page on new search + this.searchResponses(searchParams); + }); + + this.bookletSearchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(searchText => { + this.pageIndex = 0; // Reset to first page on new search + this.searchBooklets(searchText); + }); + } + + onUnitSearchChange(): void { + if (this.searchText.trim().length > 2) { + this.unitSearchSubject.next(this.searchText); + } + } + + onResponseSearchChange(): void { + this.responseSearchSubject.next({ + value: this.searchValue.trim() !== '' ? this.searchValue : undefined, + variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, + unitName: this.searchUnitName.trim() !== '' ? this.searchUnitName : undefined, + status: this.searchStatus.trim() !== '' ? this.searchStatus : undefined, + codedStatus: this.searchCodedStatus.trim() !== '' ? this.searchCodedStatus : undefined, + group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, + code: this.searchCode.trim() !== '' ? this.searchCode : undefined + }); + } + + onBookletSearchChange(): void { + if (this.bookletSearchText.trim().length > 2) { + this.bookletSearchSubject.next(this.bookletSearchText); + } + } + + onPageChange(event: PageEvent): void { + this.pageSize = event.pageSize; + this.pageIndex = event.pageIndex; + + if (this.searchMode === 'unit') { + this.searchUnits(this.searchText); + } else if (this.searchMode === 'response') { + this.searchResponses({ + value: this.searchValue.trim() !== '' ? this.searchValue : undefined, + variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, + unitName: this.searchUnitName.trim() !== '' ? this.searchUnitName : undefined, + status: this.searchStatus.trim() !== '' ? this.searchStatus : undefined, + codedStatus: this.searchCodedStatus.trim() !== '' ? this.searchCodedStatus : undefined, + group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, + code: this.searchCode.trim() !== '' ? this.searchCode : undefined + }); + } else if (this.searchMode === 'booklet') { + this.searchBooklets(this.bookletSearchText); + } + } + + setSearchMode(mode: 'unit' | 'response' | 'booklet'): void { + if (this.searchMode === mode) { + return; // Don't do anything if the mode hasn't changed + } + + this.searchMode = mode; + this.pageIndex = 0; + this.totalItems = 0; + this.unitSearchResults = []; + this.responseSearchResults = []; + this.bookletSearchResults = []; + } + + searchUnits(unitName: string): void { + if (!unitName || unitName.trim().length < 3) { + this.unitSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + return; + } + + this.isLoading = true; + // Add 1 to pageIndex because backend uses 1-based indexing + this.backendService.searchUnitsByName( + this.appService.selectedWorkspaceId, + unitName, + this.pageIndex + 1, + this.pageSize + ).subscribe({ + next: response => { + this.unitSearchResults = response.data; + this.totalItems = response.total; + this.isLoading = false; + }, + error: () => { + this.unitSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + } + }); + } + + searchResponses(searchParams: { value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }): void { + this.isLoading = true; + // Add 1 to pageIndex because backend uses 1-based indexing + this.backendService.searchResponses( + this.appService.selectedWorkspaceId, + searchParams, + this.pageIndex + 1, + this.pageSize + ).subscribe({ + next: response => { + this.responseSearchResults = response.data; + this.totalItems = response.total; + this.isLoading = false; + }, + error: () => { + this.responseSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + } + }); + } + + searchBooklets(bookletName: string): void { + if (!bookletName || bookletName.trim().length < 3) { + this.bookletSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + return; + } + + this.isLoading = true; + // Add 1 to pageIndex because backend uses 1-based indexing + this.backendService.searchBookletsByName( + this.appService.selectedWorkspaceId, + bookletName, + this.pageIndex + 1, + this.pageSize + ).subscribe({ + next: response => { + this.bookletSearchResults = response.data; + this.totalItems = response.total; + this.isLoading = false; + }, + error: () => { + this.bookletSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + } + }); + } + + close(): void { + this.dialogRef.close(); + } + + replayUnit(item: UnitSearchResult | ResponseSearchResult): void { + this.appService + .createToken(this.appService.selectedWorkspaceId, this.appService.loggedUser?.sub || '', 1) + .subscribe(token => { + const queryParams = { + auth: token + }; + const url = this.router + .serializeUrl( + this.router.createUrlTree( + [`replay/${item.personLogin}@${item.personCode}@${item.bookletName}/${item.unitAlias}/0/0`], + { queryParams: queryParams }) + ); + window.open(`#/${url}`, '_blank'); + }); + } + + deleteUnit(unit: UnitSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Unit löschen', + content: `Sind Sie sicher, dass Sie die Unit "${unit.unitName}" (${unit.unitAlias || 'ohne Alias'}) löschen möchten? Alle zugehörigen Antworten werden ebenfalls gelöscht.`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + this.backendService.deleteUnit( + this.appService.selectedWorkspaceId, + unit.unitId + ).subscribe({ + next: response => { + this.isLoading = false; + if (response.success) { + this.unitSearchResults = this.unitSearchResults.filter(u => u.unitId !== unit.unitId); + this.totalItems -= 1; + this.snackBar.open( + `Unit erfolgreich gelöscht. Unit ID: ${response.report.deletedUnit}`, + 'Schließen', + { duration: 3000 } + ); + } else { + this.snackBar.open( + `Fehler beim Löschen der Unit: ${response.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Unit. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } + + deleteResponse(response: ResponseSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Antwort löschen', + content: `Sind Sie sicher, dass Sie die Antwort für Variable "${response.variableId}" löschen möchten?`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + this.backendService.deleteResponse( + this.appService.selectedWorkspaceId, + response.responseId + ).subscribe({ + next: apiResponse => { + this.isLoading = false; + if (apiResponse.success) { + this.responseSearchResults = this.responseSearchResults.filter(r => r.responseId !== response.responseId); + this.totalItems -= 1; + this.snackBar.open( + `Antwort erfolgreich gelöscht. Antwort ID: ${apiResponse.report.deletedResponse}`, + 'Schließen', + { duration: 3000 } + ); + } else { + // Show error message + this.snackBar.open( + `Fehler beim Löschen der Antwort: ${apiResponse.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Antwort. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } + + deleteAllUnits(): void { + if (this.unitSearchResults.length === 0) { + this.snackBar.open( + 'Keine Aufgaben zum Löschen gefunden.', + 'Info', + { duration: 3000 } + ); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle gefilterten Aufgaben löschen', + content: `Sind Sie sicher, dass Sie alle ${this.unitSearchResults.length} gefilterten Aufgaben löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + const unitIds = this.unitSearchResults.map(unit => unit.unitId); + + this.backendService.deleteMultipleUnits( + this.appService.selectedWorkspaceId, + unitIds + ).subscribe({ + next: response => { + this.isLoading = false; + if (response.success) { + const deletedCount = response.report.deletedUnits.length; + this.unitSearchResults = []; + this.totalItems = 0; + this.snackBar.open( + `${deletedCount} Aufgaben erfolgreich gelöscht.`, + 'Schließen', + { duration: 3000 } + ); + } else { + this.snackBar.open( + `Fehler beim Löschen der Aufgaben: ${response.report.warnings.join(', ')}`, + 'Fehler', + { duration: 5000 } + ); + } + }, + error: () => { + this.isLoading = false; + this.snackBar.open( + 'Fehler beim Löschen der Aufgaben. Bitte versuchen Sie es später erneut.', + 'Fehler', + { duration: 5000 } + ); + } + }); + } + }); + } + + deleteAllResponses(): void { + if (this.responseSearchResults.length === 0) { + this.snackBar.open( + 'Keine Antworten zum Löschen gefunden.', + 'Info', + { duration: 3000 } + ); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle gefilterten Antworten löschen', + content: `Sind Sie sicher, dass Sie alle ${this.responseSearchResults.length} gefilterten Antworten löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const responseIds = this.responseSearchResults.map(response => response.responseId); + let successCount = 0; + let failCount = 0; + + this.isLoading = true; + + // Process each response deletion sequentially + const processNextResponse = (index: number) => { + if (index >= responseIds.length) { + // All responses processed + this.isLoading = false; + this.snackBar.open( + `${successCount} Antworten gelöscht, ${failCount} fehlgeschlagen.`, + 'OK', + { duration: 5000 } + ); + // Refresh the search results + this.searchResponses({ + value: this.searchValue.trim() !== '' ? this.searchValue : undefined, + variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, + unitName: this.searchUnitName.trim() !== '' ? this.searchUnitName : undefined, + status: this.searchStatus.trim() !== '' ? this.searchStatus : undefined, + codedStatus: this.searchCodedStatus.trim() !== '' ? this.searchCodedStatus : undefined, + group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, + code: this.searchCode.trim() !== '' ? this.searchCode : undefined + }); + return; + } + + this.backendService.deleteResponse( + this.appService.selectedWorkspaceId, + responseIds[index] + ).subscribe({ + next: response => { + if (response.success) { + successCount += 1; + } else { + failCount += 1; + } + processNextResponse(index + 1); + }, + error: () => { + failCount += 1; + processNextResponse(index + 1); + } + }); + }; + + // Start processing + processNextResponse(0); + } + }); + } + + viewBookletInfo(booklet: BookletSearchResult): void { + const loadingSnackBar = this.snackBar.open( + 'Lade Booklet-Informationen...', + '', + { duration: 3000 } + ); + + this.backendService.getBookletInfo( + this.appService.selectedWorkspaceId, + booklet.bookletName + ).subscribe({ + next: (bookletInfo: BookletInfoDto) => { + loadingSnackBar.dismiss(); + + this.dialog.open(BookletInfoDialogComponent, { + width: '800px', + data: { + bookletInfo, + bookletId: booklet.bookletName + } + }); + }, + error: () => { + loadingSnackBar.dismiss(); + this.snackBar.open( + 'Fehler beim Laden der Booklet-Informationen', + 'Fehler', + { duration: 3000 } + ); + } + }); + } + + deleteBooklet(booklet: BookletSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Booklet löschen', + content: `Sind Sie sicher, dass Sie das Booklet "${booklet.bookletName}" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + this.backendService.deleteBooklet( + this.appService.selectedWorkspaceId, + booklet.bookletId + ).subscribe({ + next: response => { + if (response.success) { + // Remove the deleted booklet from the results + this.bookletSearchResults = this.bookletSearchResults.filter( + b => b.bookletId !== booklet.bookletId + ); + this.totalItems -= 1; + + this.snackBar.open( + `Booklet "${booklet.bookletName}" wurde erfolgreich gelöscht.`, + 'OK', + { duration: 3000 } + ); + } else { + this.snackBar.open( + `Fehler beim Löschen des Booklets: ${response.report.warnings.join(', ')}`, + 'OK', + { duration: 5000 } + ); + } + this.isLoading = false; + }, + error: () => { + this.snackBar.open( + 'Fehler beim Löschen des Booklets. Bitte versuchen Sie es erneut.', + 'OK', + { duration: 5000 } + ); + this.isLoading = false; + } + }); + } + }); + } + + deleteAllBooklets(): void { + if (this.bookletSearchResults.length === 0) { + this.snackBar.open( + 'Keine Booklets zum Löschen gefunden.', + 'Info', + { duration: 3000 } + ); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle gefilterten Booklets löschen', + content: `Sind Sie sicher, dass Sie alle ${this.bookletSearchResults.length} gefilterten Booklets löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const bookletIds = this.bookletSearchResults.map(booklet => booklet.bookletId); + let successCount = 0; + let failCount = 0; + + this.isLoading = true; + + // Process each booklet deletion sequentially + const processNextBooklet = (index: number) => { + if (index >= bookletIds.length) { + // All booklets processed + this.isLoading = false; + this.snackBar.open( + `${successCount} Booklets gelöscht, ${failCount} fehlgeschlagen.`, + 'OK', + { duration: 5000 } + ); + // Refresh the search results + this.searchBooklets(this.bookletSearchText); + return; + } + + this.backendService.deleteBooklet( + this.appService.selectedWorkspaceId, + bookletIds[index] + ).subscribe({ + next: response => { + if (response.success) { + successCount += 1; + } else { + failCount += 1; + } + processNextBooklet(index + 1); + }, + error: () => { + failCount += 1; + processNextBooklet(index + 1); + } + }); + }; + + // Start processing + processNextBooklet(0); + } + }); + } +} diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html index 70ce17be6..247f937f0 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.html @@ -21,7 +21,7 @@ code Kodieren - + search Suchen diff --git a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts index d25f32523..4422e98f3 100755 --- a/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts +++ b/apps/frontend/src/app/ws-admin/components/test-results/test-results.component.ts @@ -44,7 +44,7 @@ import { LogDialogComponent } from '../booklet-log-dialog/log-dialog.component'; import { UnitLogsDialogComponent } from '../unit-logs-dialog/unit-logs-dialog.component'; import { TagDialogComponent } from '../tag-dialog/tag-dialog.component'; import { NoteDialogComponent } from '../note-dialog/note-dialog.component'; -import { UnitSearchDialogComponent } from '../unit-search-dialog/unit-search-dialog.component'; +import { TestResultsSearchComponent } from '../test-results-search/test-results-search.component'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; import { UnitTagDto } from '../../../../../../../api-dto/unit-tags/unit-tag.dto'; import { CreateUnitTagDto } from '../../../../../../../api-dto/unit-tags/create-unit-tag.dto'; @@ -1261,11 +1261,11 @@ export class TestResultsComponent implements OnInit, OnDestroy { ); } - openUnitSearchDialog(): void { - this.dialog.open(UnitSearchDialogComponent, { + openTestResultsSearchDialog(): void { + this.dialog.open(TestResultsSearchComponent, { width: '1200px', data: { - title: 'Aufgaben suchen' + title: 'Testergebnisse suchen' } }); } diff --git a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html index 8244e0c28..5ede7f031 100644 --- a/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html +++ b/apps/frontend/src/app/ws-admin/components/unit-search-dialog/unit-search-dialog.component.html @@ -3,8 +3,9 @@

{{ data.title }}

- - + + +
@@ -65,6 +66,17 @@

{{ data.title }}

} + + @if (searchMode === 'booklet') { +
+ + Booklet suchen + + search + +
+ } +
@if (isLoading) {
@@ -84,10 +96,15 @@

{{ data.title }}

search_off

Keine Ergebnisse gefunden für die angegebenen Suchkriterien

+ } @else if (searchMode === 'booklet' && bookletSearchResults.length === 0 && bookletSearchText.trim().length > 2) { +
+ search_off +

Keine Ergebnisse gefunden für "{{ bookletSearchText }}"

+
} @else if (searchMode === 'unit' && searchText.trim().length <= 2) {
info -

Geben Sie Zeichen ein, um die Suche zu starten

+

Geben Sie mindestens 3 Zeichen ein, um die Suche zu starten

} @else if (searchMode === 'response' && searchValue.trim() === '' && @@ -101,6 +118,11 @@

{{ data.title }}

info

Geben Sie Zeichen in eines der Suchfelder ein, um die Suche zu starten

+ } @else if (searchMode === 'booklet' && bookletSearchText.trim().length <= 2) { +
+ info +

Geben Sie mindestens 3 Zeichen ein, um die Suche zu starten

+
} @else { @if (searchMode === 'unit') { @@ -299,6 +321,66 @@

{{ data.title }}

} + + @if (searchMode === 'booklet') { + + @if (bookletSearchResults.length > 0) { +
+ +
+ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Booklet Name{{ booklet.bookletName }}Code{{ booklet.personCode }}Login{{ booklet.personLogin }}Gruppe{{ booklet.personGroup }}Units{{ booklet.units.length }}Aktionen + + +
+ } + (); private responseSearchSubject = new Subject<{ value?: string; variableId?: string; unitName?: string; status?: string; codedStatus?: string; group?: string; code?: string }>(); + private bookletSearchSubject = new Subject(); private readonly SEARCH_DEBOUNCE_TIME = 500; totalItems: number = 0; @@ -134,6 +154,14 @@ export class UnitSearchDialogComponent implements OnInit { this.pageIndex = 0; // Reset to first page on new search this.searchResponses(searchParams); }); + + this.bookletSearchSubject.pipe( + debounceTime(this.SEARCH_DEBOUNCE_TIME), + distinctUntilChanged() + ).subscribe(searchText => { + this.pageIndex = 0; // Reset to first page on new search + this.searchBooklets(searchText); + }); } onUnitSearchChange(): void { @@ -154,13 +182,19 @@ export class UnitSearchDialogComponent implements OnInit { }); } + onBookletSearchChange(): void { + if (this.bookletSearchText.trim().length > 2) { + this.bookletSearchSubject.next(this.bookletSearchText); + } + } + onPageChange(event: PageEvent): void { this.pageSize = event.pageSize; this.pageIndex = event.pageIndex; if (this.searchMode === 'unit') { this.searchUnits(this.searchText); - } else { + } else if (this.searchMode === 'response') { this.searchResponses({ value: this.searchValue.trim() !== '' ? this.searchValue : undefined, variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, @@ -170,25 +204,34 @@ export class UnitSearchDialogComponent implements OnInit { group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, code: this.searchCode.trim() !== '' ? this.searchCode : undefined }); + } else if (this.searchMode === 'booklet') { + this.searchBooklets(this.bookletSearchText); } } - toggleSearchMode(): void { - this.searchMode = this.searchMode === 'unit' ? 'response' : 'unit'; + setSearchMode(mode: 'unit' | 'response' | 'booklet'): void { + if (this.searchMode === mode) { + return; // Don't do anything if the mode hasn't changed + } + + this.searchMode = mode; this.pageIndex = 0; this.totalItems = 0; this.unitSearchResults = []; this.responseSearchResults = []; + this.bookletSearchResults = []; } searchUnits(unitName: string): void { if (!unitName || unitName.trim().length < 3) { this.unitSearchResults = []; this.totalItems = 0; + this.isLoading = false; return; } this.isLoading = true; + // Add 1 to pageIndex because backend uses 1-based indexing this.backendService.searchUnitsByName( this.appService.selectedWorkspaceId, unitName, @@ -230,6 +273,35 @@ export class UnitSearchDialogComponent implements OnInit { }); } + searchBooklets(bookletName: string): void { + if (!bookletName || bookletName.trim().length < 3) { + this.bookletSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + return; + } + + this.isLoading = true; + // Add 1 to pageIndex because backend uses 1-based indexing + this.backendService.searchBookletsByName( + this.appService.selectedWorkspaceId, + bookletName, + this.pageIndex + 1, + this.pageSize + ).subscribe({ + next: response => { + this.bookletSearchResults = response.data; + this.totalItems = response.total; + this.isLoading = false; + }, + error: () => { + this.bookletSearchResults = []; + this.totalItems = 0; + this.isLoading = false; + } + }); + } + close(): void { this.dialogRef.close(); } @@ -433,47 +505,211 @@ export class UnitSearchDialogComponent implements OnInit { dialogRef.afterClosed().subscribe(result => { if (result) { - this.isLoading = true; const responseIds = this.responseSearchResults.map(response => response.responseId); + let successCount = 0; + let failCount = 0; + + this.isLoading = true; + + // Process each response deletion sequentially + const processNextResponse = (index: number) => { + if (index >= responseIds.length) { + // All responses processed + this.isLoading = false; + this.snackBar.open( + `${successCount} Antworten gelöscht, ${failCount} fehlgeschlagen.`, + 'OK', + { duration: 5000 } + ); + // Refresh the search results + this.searchResponses({ + value: this.searchValue.trim() !== '' ? this.searchValue : undefined, + variableId: this.searchVariableId.trim() !== '' ? this.searchVariableId : undefined, + unitName: this.searchUnitName.trim() !== '' ? this.searchUnitName : undefined, + status: this.searchStatus.trim() !== '' ? this.searchStatus : undefined, + codedStatus: this.searchCodedStatus.trim() !== '' ? this.searchCodedStatus : undefined, + group: this.searchGroup.trim() !== '' ? this.searchGroup : undefined, + code: this.searchCode.trim() !== '' ? this.searchCode : undefined + }); + return; + } + + this.backendService.deleteResponse( + this.appService.selectedWorkspaceId, + responseIds[index] + ).subscribe({ + next: response => { + if (response.success) { + successCount += 1; + } else { + failCount += 1; + } + processNextResponse(index + 1); + }, + error: () => { + failCount += 1; + processNextResponse(index + 1); + } + }); + }; + + // Start processing + processNextResponse(0); + } + }); + } + + viewBookletInfo(booklet: BookletSearchResult): void { + const loadingSnackBar = this.snackBar.open( + 'Lade Booklet-Informationen...', + '', + { duration: 3000 } + ); + + this.backendService.getBookletInfo( + this.appService.selectedWorkspaceId, + booklet.bookletName + ).subscribe({ + next: (bookletInfo: BookletInfoDto) => { + loadingSnackBar.dismiss(); + + this.dialog.open(BookletInfoDialogComponent, { + width: '800px', + data: { + bookletInfo, + bookletId: booklet.bookletName + } + }); + }, + error: () => { + loadingSnackBar.dismiss(); + this.snackBar.open( + 'Fehler beim Laden der Booklet-Informationen', + 'Fehler', + { duration: 3000 } + ); + } + }); + } + + deleteBooklet(booklet: BookletSearchResult): void { + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Booklet löschen', + content: `Sind Sie sicher, dass Sie das Booklet "${booklet.bookletName}" löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Löschen', + showCancel: true + } as ConfirmDialogData + }); - this.backendService.deleteMultipleResponses( + dialogRef.afterClosed().subscribe(result => { + if (result) { + this.isLoading = true; + this.backendService.deleteBooklet( this.appService.selectedWorkspaceId, - responseIds + booklet.bookletId ).subscribe({ next: response => { - this.isLoading = false; if (response.success) { - const deletedCount = response.report.deletedResponses.length; - - // Clear the search results - this.responseSearchResults = []; - this.totalItems = 0; + // Remove the deleted booklet from the results + this.bookletSearchResults = this.bookletSearchResults.filter( + b => b.bookletId !== booklet.bookletId + ); + this.totalItems -= 1; - // Show success message this.snackBar.open( - `${deletedCount} Antworten erfolgreich gelöscht.`, - 'Schließen', + `Booklet "${booklet.bookletName}" wurde erfolgreich gelöscht.`, + 'OK', { duration: 3000 } ); } else { - // Show error message this.snackBar.open( - `Fehler beim Löschen der Antworten: ${response.report.warnings.join(', ')}`, - 'Fehler', + `Fehler beim Löschen des Booklets: ${response.report.warnings.join(', ')}`, + 'OK', { duration: 5000 } ); } + this.isLoading = false; }, error: () => { - this.isLoading = false; this.snackBar.open( - 'Fehler beim Löschen der Antworten. Bitte versuchen Sie es später erneut.', - 'Fehler', + 'Fehler beim Löschen des Booklets. Bitte versuchen Sie es erneut.', + 'OK', { duration: 5000 } ); + this.isLoading = false; } }); } }); } + + deleteAllBooklets(): void { + if (this.bookletSearchResults.length === 0) { + this.snackBar.open( + 'Keine Booklets zum Löschen gefunden.', + 'Info', + { duration: 3000 } + ); + return; + } + + const dialogRef = this.dialog.open(ConfirmDialogComponent, { + width: '400px', + data: { + title: 'Alle gefilterten Booklets löschen', + content: `Sind Sie sicher, dass Sie alle ${this.bookletSearchResults.length} gefilterten Booklets löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`, + confirmButtonLabel: 'Alle löschen', + showCancel: true + } as ConfirmDialogData + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + const bookletIds = this.bookletSearchResults.map(booklet => booklet.bookletId); + let successCount = 0; + let failCount = 0; + + this.isLoading = true; + + // Process each booklet deletion sequentially + const processNextBooklet = (index: number) => { + if (index >= bookletIds.length) { + // All booklets processed + this.isLoading = false; + this.snackBar.open( + `${successCount} Booklets gelöscht, ${failCount} fehlgeschlagen.`, + 'OK', + { duration: 5000 } + ); + // Refresh the search results + this.searchBooklets(this.bookletSearchText); + return; + } + + this.backendService.deleteBooklet( + this.appService.selectedWorkspaceId, + bookletIds[index] + ).subscribe({ + next: response => { + if (response.success) { + successCount += 1; + } else { + failCount += 1; + } + processNextBooklet(index + 1); + }, + error: () => { + failCount += 1; + processNextBooklet(index + 1); + } + }); + }; + + // Start processing + processNextBooklet(0); + } + }); + } } From 1a8654939299c37e5f2b6552bdccef81a4fca9c2 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 17:53:11 +0200 Subject: [PATCH 05/12] Add restart failed coding job option --- .../workspace/workspace-coding.controller.ts | 29 ++++++++++++ .../services/workspace-coding.service.ts | 44 +++++++++++++++++++ .../test-person-coding.component.html | 6 +++ .../test-person-coding.component.ts | 19 ++++++++ .../services/test-person-coding.service.ts | 17 +++++++ 5 files changed, 115 insertions(+) diff --git a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts index a24802a7e..2fe7d9172 100644 --- a/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts +++ b/apps/backend/src/app/admin/workspace/workspace-coding.controller.ts @@ -487,6 +487,35 @@ export class WorkspaceCodingController { return this.workspaceCodingService.resumeJob(jobId); } + @Get(':workspace_id/coding/job/:jobId/restart') + @UseGuards(JwtAuthGuard, WorkspaceGuard) + @ApiTags('coding') + @ApiParam({ name: 'workspace_id', type: Number }) + @ApiParam({ name: 'jobId', type: String, description: 'ID of the failed background job to restart' }) + @ApiOkResponse({ + description: 'Job restart request processed.', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Whether the restart request was successful' + }, + message: { + type: 'string', + description: 'Message describing the result of the restart request' + }, + jobId: { + type: 'string', + description: 'ID of the new job created from the restart' + } + } + } + }) + async restartJob(@Param('jobId') jobId: string): Promise<{ success: boolean; message: string; jobId?: string }> { + return this.workspaceCodingService.restartJob(jobId); + } + @Get(':workspace_id/coding/missings-profiles') @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiTags('coding') diff --git a/apps/backend/src/app/database/services/workspace-coding.service.ts b/apps/backend/src/app/database/services/workspace-coding.service.ts index 8ed343129..aed65d1e5 100644 --- a/apps/backend/src/app/database/services/workspace-coding.service.ts +++ b/apps/backend/src/app/database/services/workspace-coding.service.ts @@ -1740,6 +1740,50 @@ export class WorkspaceCodingService { } } + /** + * Restart a failed job + * @param jobId The job ID to restart + * @returns Success status and message, with new job ID if successful + */ + async restartJob(jobId: string): Promise<{ success: boolean; message: string; jobId?: string }> { + try { + // Get job from Bull queue + const bullJob = await this.jobQueueService.getTestPersonCodingJob(jobId); + if (!bullJob) { + return { success: false, message: `Job with ID ${jobId} not found` }; + } + + // Check if job is failed + const state = await bullJob.getState(); + if (state !== 'failed') { + return { + success: false, + message: `Job with ID ${jobId} is not failed and cannot be restarted` + }; + } + + // Create a new job with the same data + const newJob = await this.jobQueueService.addTestPersonCodingJob({ + workspaceId: bullJob.data.workspaceId, + personIds: bullJob.data.personIds, + groupNames: bullJob.data.groupNames + }); + + // Delete the old job + await this.jobQueueService.deleteTestPersonCodingJob(jobId); + + this.logger.log(`Job ${jobId} has been restarted as job ${newJob.id}`); + return { + success: true, + message: `Job ${jobId} has been restarted as job ${newJob.id}`, + jobId: newJob.id.toString() + }; + } catch (error) { + this.logger.error(`Error restarting job: ${error.message}`, error.stack); + return { success: false, message: `Error restarting job: ${error.message}` }; + } + } + /** * Get jobs only from Redis Bull queue for a workspace * @param workspaceId The workspace ID diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html index 5d2116570..f7f36af0b 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.html @@ -120,6 +120,12 @@ error + + + person diff --git a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts index 3dfa10b88..3f01f2e45 100644 --- a/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts +++ b/apps/frontend/src/app/coding/components/test-person-coding/test-person-coding.component.ts @@ -311,6 +311,25 @@ export class TestPersonCodingComponent implements OnInit { }); } + restartJob(jobId: string): void { + if (!jobId) return; + + this.testPersonCodingService.restartJob(this.workspaceId, jobId) + .subscribe(result => { + if (result.success) { + this.snackBar.open(result.message || 'Auftrag wurde neu gestartet', 'Schließen', { duration: 3000 }); + if (result.jobId) { + // If a new job was created, start polling its status + this.activeJobId = result.jobId; + this.startJobStatusPolling(result.jobId); + } + this.loadAllJobs(); + } else { + this.snackBar.open(`Fehler beim Neustarten des Auftrags: ${result.message}`, 'Schließen', { duration: 5000 }); + } + }); + } + showJobResult(job: JobInfo): void { if (!job.result) { this.snackBar.open('Keine Ergebnisse für diesen Auftrag verfügbar', 'Schließen', { duration: 3000 }); diff --git a/apps/frontend/src/app/coding/services/test-person-coding.service.ts b/apps/frontend/src/app/coding/services/test-person-coding.service.ts index 91ecc1fe7..adb5c062b 100644 --- a/apps/frontend/src/app/coding/services/test-person-coding.service.ts +++ b/apps/frontend/src/app/coding/services/test-person-coding.service.ts @@ -264,4 +264,21 @@ export class TestPersonCodingService { catchError(() => of([])) ); } + + /** + * Restart a failed job + * @param workspaceId Workspace ID + * @param jobId Job ID to restart + * @returns Observable with restart result + */ + restartJob(workspaceId: number, jobId: string): Observable<{ success: boolean; message: string; jobId?: string }> { + return this.http + .get<{ success: boolean; message: string; jobId?: string }>( + `${this.serverUrl}admin/workspace/${workspaceId}/coding/job/${jobId}/restart`, + { headers: this.authHeader } + ) + .pipe( + catchError(() => of({ success: false, message: `Failed to restart job ${jobId}` })) + ); + } } From 83996e396ce8b53e16bea86a6be9709632164945 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:14:15 +0200 Subject: [PATCH 06/12] Fix linting --- .../booklet-search-dialog.component.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts index 93ac5543f..6530d99c2 100644 --- a/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts +++ b/apps/frontend/src/app/ws-admin/components/booklet-search-dialog/booklet-search-dialog.component.ts @@ -124,7 +124,7 @@ export class BookletSearchDialogComponent implements OnInit { this.currentPage, this.pageSize ).subscribe({ - next: (response) => { + next: response => { this.bookletSearchResults = response.data; this.totalResults = response.total; this.isLoading = false; @@ -162,14 +162,14 @@ export class BookletSearchDialogComponent implements OnInit { } as ConfirmDialogData }); - dialogRef.afterClosed().subscribe((result) => { + dialogRef.afterClosed().subscribe(result => { if (result) { this.isLoading = true; this.backendService.deleteBooklet( this.appService.selectedWorkspaceId, booklet.bookletId ).subscribe({ - next: (response) => { + next: response => { if (response.success) { // Remove the deleted booklet from the results this.bookletSearchResults = this.bookletSearchResults.filter( @@ -218,12 +218,11 @@ export class BookletSearchDialogComponent implements OnInit { } as ConfirmDialogData }); - dialogRef.afterClosed().subscribe((result) => { + dialogRef.afterClosed().subscribe(result => { if (result) { const bookletIds = this.bookletSearchResults.map(booklet => booklet.bookletId); let successCount = 0; let failCount = 0; - let processedCount = 0; this.isLoading = true; @@ -246,18 +245,16 @@ export class BookletSearchDialogComponent implements OnInit { this.appService.selectedWorkspaceId, bookletIds[index] ).subscribe({ - next: (response) => { + next: response => { if (response.success) { - successCount++; + successCount += 1; } else { - failCount++; + failCount += 1; } - processedCount++; processNextBooklet(index + 1); }, error: () => { - failCount++; - processedCount++; + failCount += 1; processNextBooklet(index + 1); } }); From c19204658f2eddece5ddc4a7de728033c17965af Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:15:55 +0200 Subject: [PATCH 07/12] Add watermark to replay view with person und unit data --- .../components/replay/replay.component.html | 4 ++++ .../components/replay/replay.component.scss | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.html b/apps/frontend/src/app/replay/components/replay/replay.component.html index fbab9d866..cd6835b80 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.html +++ b/apps/frontend/src/app/replay/components/replay/replay.component.html @@ -32,4 +32,8 @@ (invalidPage)="checkPageError($event)"> } + + @if (testPerson && unitId) { +
{{ testPerson }} - {{ unitId }}
+ } diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.scss b/apps/frontend/src/app/replay/components/replay/replay.component.scss index e8529a911..c68a76012 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.scss +++ b/apps/frontend/src/app/replay/components/replay/replay.component.scss @@ -3,6 +3,7 @@ background-color: white; display: flex; flex-direction: column; + position: relative; } .print-mode { @@ -92,3 +93,20 @@ .total-units { color: #757575; } + +// Watermark styles +.watermark { + position: absolute; + bottom: 15px; + right: 15px; + font-size: 14px; + color: rgba(0, 0, 0, 0.35); + z-index: 100; + pointer-events: none; + user-select: none; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 300px; + overflow: hidden; + font-weight: 500; +} From 0d91d31a0d8b7df01a5c7f28e89cf532667b9d89 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:17:28 +0200 Subject: [PATCH 08/12] Improve replay error handling --- .../components/replay/replay.component.ts | 50 ++++++++----------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index e617188cd..159dfc96c 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -101,13 +101,9 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } try { - // Decode the Base64 string to get the JSON string const jsonString = atob(encodedData); - - // Parse the JSON string to get the BookletReplay object return JSON.parse(jsonString) as BookletReplay; } catch (error) { - // Error occurred while deserializing booklet data return null; } } @@ -121,14 +117,10 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { const queryParams = await firstValueFrom(this.route.queryParams); this.isBookletMode = queryParams.mode === 'booklet'; - - // If in booklet mode and bookletData is provided, deserialize it if (this.isBookletMode && queryParams.bookletData) { const deserializedBooklet = this.deserializeBookletData(queryParams.bookletData); if (deserializedBooklet) { - // Successfully deserialized booklet data from URL this.bookletData = deserializedBooklet; - // Update the component state this.currentUnitIndex = deserializedBooklet.currentUnitIndex; this.totalUnits = deserializedBooklet.units.length; } @@ -139,9 +131,13 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (!tokenValidation.isValid) { this.setIsLoaded(); if (tokenValidation.errorType === 'token_expired') { - this.openErrorSnackBar(this.getErrorMessages().tokenExpired, 'Schließen'); + const errorMessage = this.getErrorMessages().tokenExpired; + this.openErrorSnackBar(errorMessage, 'Schließen'); + this.storeErrorInStatistics(errorMessage); } else { - this.openErrorSnackBar(this.getErrorMessages().tokenInvalid, 'Schließen'); + const errorMessage = this.getErrorMessages().tokenInvalid; + this.openErrorSnackBar(errorMessage, 'Schließen'); + this.storeErrorInStatistics(errorMessage); } return; } @@ -174,20 +170,19 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (this.anchor) { highlightAspectSectionWithAnchor(this.unitPlayerComponent.hostingIframe.nativeElement, this.anchor); scrollToElementByAlias(this.unitPlayerComponent.hostingIframe.nativeElement, this.anchor); - } else { - // When no anchor is provided, scroll to the top of the content - // this.scrollToTop(); } } }, 1000); } } else { + this.storeErrorInStatistics('QueryError'); ReplayComponent.throwError('QueryError'); } } else if (testPersonInput && unitIdInput) { this.setTestPerson(testPersonInput); this.unitId = unitIdInput; } else if (Object.keys(params).length !== 4 && !this.isPrintMode) { + this.storeErrorInStatistics('ParamsError'); ReplayComponent.throwError('ParamsError'); } } catch (error) { @@ -217,6 +212,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { setTestPerson(testPerson: string): void { if (!isTestperson(testPerson)) { + this.storeErrorInStatistics('TestPersonError'); ReplayComponent.throwError('TestPersonError'); } else { this.testPerson = testPerson; @@ -225,6 +221,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { private checkUnitId(unitFile: FilesDto[]): void { if (!unitFile || !unitFile[0]) { + this.storeErrorInStatistics('UnitIdError'); ReplayComponent.throwError('UnitIdError'); } else { this.cacheUnitData(unitFile[0]); @@ -232,7 +229,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } async ngOnChanges(changes: SimpleChanges): Promise { - // Handle unitIdInput changes // eslint-disable-next-line @typescript-eslint/dot-notation if (typeof changes['unitIdInput']?.currentValue === 'undefined') { this.resetUnitData(); @@ -249,9 +245,13 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (!tokenValidation.isValid) { this.setIsLoaded(); if (tokenValidation.errorType === 'token_expired') { - this.openErrorSnackBar(this.getErrorMessages().tokenExpired, 'Schließen'); + const errorMessage = this.getErrorMessages().tokenExpired; + this.openErrorSnackBar(errorMessage, 'Schließen'); + this.storeErrorInStatistics(errorMessage); } else { - this.openErrorSnackBar(this.getErrorMessages().tokenInvalid, 'Schließen'); + const errorMessage = this.getErrorMessages().tokenInvalid; + this.openErrorSnackBar(errorMessage, 'Schließen'); + this.storeErrorInStatistics(errorMessage); } return Promise.resolve(); } @@ -375,8 +375,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { ])); const endTime = performance.now(); const duration = Math.floor(endTime - startTime); - logger.log(`Replay-Dauer: ${duration.toFixed(2)}ms`); - if (duration) { if (duration >= 1) { try { @@ -386,7 +384,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (this.testPerson) { const parts = this.testPerson.split('@'); - console.log('parts', parts); if (parts.length > 0) { testPersonLogin = parts[0]; testPersonCode = parts[1]; @@ -426,7 +423,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } } - // Reset the start time for the next unit this.replayStartTime = performance.now(); } @@ -447,7 +443,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { notCurrent: `Seite mit der ID "${this.page || ''}" kann nicht ausgewählt werden`, tokenExpired: 'Das Authentisierungs-Token ist abgelaufen', tokenInvalid: 'Das Authentisierungs-Token ist ungültig', - unknown: 'Unbekannter Fehler' + unknown: `Unbekannter Fehler für Aufgabe "${this.unitId || ''}" von Testperson "${this.testPerson || ''}"` }; } @@ -470,20 +466,15 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } private storeErrorInStatistics(errorMessage: string): void { - // Calculate duration from start time to now const duration = this.replayStartTime ? Math.round(performance.now() - this.replayStartTime) : 0; - - // Get auth token from local storage const authToken = localStorage.getItem('authToken'); if (!authToken) return; try { - // Extract workspace ID from token const decoded: JwtPayload & { workspace: string } = jwtDecode(authToken); const workspaceId = Number(decoded?.workspace); if (!workspaceId) return; - // Extract test person information let testPersonLogin = ''; let testPersonCode = ''; let bookletId = ''; @@ -496,11 +487,8 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { bookletId = parts[2]; } } - - // Construct the replay URL const replayUrl = window.location.href; - // Store the replay statistics with error information this.backendService.storeReplayStatistics(workspaceId, { unitId: this.unitId || 'unknown', bookletId, @@ -525,7 +513,9 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { checkPageError(pageError: 'notInList' | 'notCurrent' | null): void { if (pageError) { - this.openPageErrorSnackBar(this.getErrorMessages()[pageError], 'Schließen'); + const errorMessage = this.getErrorMessages()[pageError]; + this.openPageErrorSnackBar(errorMessage, 'Schließen'); + this.storeErrorInStatistics(errorMessage); } else if (this.pageErrorSnackbarRef) { this.pageErrorSnackBar.dismiss(); this.pageErrorSnackbarRef = null; From 9764c597bb37cc7e7484cbdb54f6c322a07e3270 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:54:10 +0200 Subject: [PATCH 09/12] Extract booklet replay component --- .../booklet-replay.component.html | 17 +++ .../booklet-replay.component.scss | 78 ++++++++++++ .../booklet-replay.component.ts | 82 +++++++++++++ .../components/replay/replay.component.html | 22 +--- .../components/replay/replay.component.ts | 112 +++++++----------- 5 files changed, 222 insertions(+), 89 deletions(-) create mode 100644 apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.html create mode 100644 apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.scss create mode 100644 apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.ts diff --git a/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.html b/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.html new file mode 100644 index 000000000..f33f44a64 --- /dev/null +++ b/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.html @@ -0,0 +1,17 @@ +
+ +
diff --git a/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.scss b/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.scss new file mode 100644 index 000000000..01b6f7112 --- /dev/null +++ b/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.scss @@ -0,0 +1,78 @@ +// Booklet navigation styles +.booklet-navigation { + background-color: #f5f9ff; + padding: 12px 20px; + border-bottom: 1px solid #e0e0e0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + z-index: 10; +} + +.navigation-controls { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +.nav-button { + background-color: #1976d2; + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + transition: background-color 0.2s ease; + + &:hover { + background-color: #1565c0; + } + + &:disabled { + background-color: #cccccc; + cursor: not-allowed; + } +} + +.prev-button { + .nav-icon { + margin-right: 8px; + } +} + +.next-button { + .nav-icon { + margin-left: 8px; + } +} + +.unit-position { + display: flex; + align-items: center; + font-size: 16px; + font-weight: 500; + color: #333; + background-color: white; + padding: 6px 16px; + border-radius: 20px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.current-position { + color: #1976d2; + font-weight: 700; +} + +.position-separator { + margin: 0 4px; + color: #757575; +} + +.total-units { + color: #757575; +} diff --git a/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.ts b/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.ts new file mode 100644 index 000000000..4bc096110 --- /dev/null +++ b/apps/frontend/src/app/replay/components/booklet-replay/booklet-replay.component.ts @@ -0,0 +1,82 @@ +import { + Component, + input, + output +} from '@angular/core'; +import { BookletReplay, BookletReplayUnit } from '../../../services/booklet-replay.service'; + +@Component({ + selector: 'coding-box-booklet-replay', + templateUrl: './booklet-replay.component.html', + styleUrls: ['./booklet-replay.component.scss'], + standalone: true +}) +export class BookletReplayComponent { + bookletData = input(null); + unitChanged = output(); + + currentUnitIndex = 0; + totalUnits = 0; + + // Getters for the current state + get currentUnit(): BookletReplayUnit | null { + const data = this.bookletData(); + if (!data || !data.units || data.units.length === 0) { + return null; + } + return data.units[data.currentUnitIndex]; + } + + // Navigation methods + nextUnit(): void { + const data = this.bookletData(); + if (!data || !this.hasNextUnit()) { + return; + } + + const nextIndex = data.currentUnitIndex + 1; + if (nextIndex < data.units.length) { + const nextUnit = data.units[nextIndex]; + this.unitChanged.emit(nextUnit); + } + } + + previousUnit(): void { + const data = this.bookletData(); + if (!data || !this.hasPreviousUnit()) { + return; + } + + const prevIndex = data.currentUnitIndex - 1; + if (prevIndex >= 0) { + const prevUnit = data.units[prevIndex]; + this.unitChanged.emit(prevUnit); + } + } + + hasNextUnit(): boolean { + const data = this.bookletData(); + if (!data) return false; + + return data.currentUnitIndex < data.units.length - 1; + } + + hasPreviousUnit(): boolean { + const data = this.bookletData(); + if (!data) return false; + + return data.currentUnitIndex > 0; + } + + // Update the current state based on the input + ngOnChanges(): void { + const data = this.bookletData(); + if (data) { + this.currentUnitIndex = data.currentUnitIndex; + this.totalUnits = data.units.length; + } else { + this.currentUnitIndex = 0; + this.totalUnits = 0; + } + } +} diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.html b/apps/frontend/src/app/replay/components/replay/replay.component.html index cd6835b80..c1d80037f 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.html +++ b/apps/frontend/src/app/replay/components/replay/replay.component.html @@ -1,25 +1,11 @@
- @if (isBookletMode && !!player && unitDef) { -
- -
+ + } @if (!!player && unitDef) { diff --git a/apps/frontend/src/app/replay/components/replay/replay.component.ts b/apps/frontend/src/app/replay/components/replay/replay.component.ts index 159dfc96c..d5a98167e 100755 --- a/apps/frontend/src/app/replay/components/replay/replay.component.ts +++ b/apps/frontend/src/app/replay/components/replay/replay.component.ts @@ -25,11 +25,22 @@ import { FilesDto } from '../../../../../../../api-dto/files/files.dto'; import { ErrorMessages } from '../../models/error-messages.model'; import { validateToken, isTestperson } from '../../utils/token-utils'; import { scrollToElementByAlias, highlightAspectSectionWithAnchor } from '../../utils/dom-utils'; -import { BookletReplay } from '../../../services/booklet-replay.service'; +import { BookletReplay, BookletReplayUnit } from '../../../services/booklet-replay.service'; +import { BookletReplayComponent } from '../booklet-replay/booklet-replay.component'; @Component({ selector: 'coding-box-replay', - imports: [MatFormFieldModule, MatInputModule, MatButtonModule, ReactiveFormsModule, TranslateModule, UnitPlayerComponent, SpinnerComponent, FormsModule], + imports: [ + MatFormFieldModule, + MatInputModule, + MatButtonModule, + ReactiveFormsModule, + TranslateModule, + UnitPlayerComponent, + SpinnerComponent, + FormsModule, + BookletReplayComponent + ], templateUrl: './replay.component.html', styleUrl: './replay.component.scss' }) @@ -62,7 +73,7 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { private routerSubscription: Subscription | null = null; readonly testPersonInput = input(); readonly unitIdInput = input(); - private bookletData: BookletReplay | null = null; + protected bookletData: BookletReplay | null = null; @ViewChild(UnitPlayerComponent) unitPlayerComponent: UnitPlayerComponent | undefined; private replayStartTime: number = 0; // Track when replay viewing starts @@ -453,7 +464,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { if (error.status === 401) { messageKey = '401' as keyof ErrorMessages; } else if (error.status === 404 && this.unitId && this.testPerson) { - // If it's a 404 error and we have unitId and testPerson, it's likely a ResponsesError messageKey = 'ResponsesError' as keyof ErrorMessages; } else { messageKey = error.message as keyof ErrorMessages; @@ -511,6 +521,33 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { } } + handleUnitChanged(unit: BookletReplayUnit): void { + if (unit && unit.name !== this.unitId) { + this.unitId = unit.name; + + if (this.authToken) { + const decoded: JwtPayload & { workspace: string } = jwtDecode(this.authToken); + const workspace = decoded?.workspace; + if (workspace) { + this.getUnitData(Number(workspace), this.authToken).then(unitData => { + this.setUnitProperties(unitData); + }); + } + } + + if (this.bookletData) { + const newIndex = this.bookletData.units.findIndex(u => u.name === unit.name); + if (newIndex >= 0) { + this.bookletData = { + ...this.bookletData, + currentUnitIndex: newIndex + }; + this.currentUnitIndex = newIndex; + } + } + } + } + checkPageError(pageError: 'notInList' | 'notCurrent' | null): void { if (pageError) { const errorMessage = this.getErrorMessages()[pageError]; @@ -535,73 +572,6 @@ export class ReplayComponent implements OnInit, OnDestroy, OnChanges { this.responses = undefined; } - nextUnit(): void { - if (this.isBookletMode && this.bookletData) { - if (this.bookletData.currentUnitIndex < this.bookletData.units.length - 1) { - this.bookletData = { - ...this.bookletData, - currentUnitIndex: this.bookletData.currentUnitIndex + 1 - }; - - this.currentUnitIndex = this.bookletData.currentUnitIndex; - - const currentUnit = this.bookletData.units[this.bookletData.currentUnitIndex]; - if (currentUnit && currentUnit.name !== this.unitId) { - this.unitId = currentUnit.name; - if (this.authToken) { - const decoded: JwtPayload & { workspace: string } = jwtDecode(this.authToken); - const workspace = decoded?.workspace; - if (workspace) { - this.getUnitData(Number(workspace), this.authToken).then(unitData => { - this.setUnitProperties(unitData); - }); - } - } - } - } - } - } - - previousUnit(): void { - if (this.isBookletMode && this.bookletData) { - if (this.bookletData.currentUnitIndex > 0) { - this.bookletData = { - ...this.bookletData, - currentUnitIndex: this.bookletData.currentUnitIndex - 1 - }; - - this.currentUnitIndex = this.bookletData.currentUnitIndex; - - const currentUnit = this.bookletData.units[this.bookletData.currentUnitIndex]; - if (currentUnit && currentUnit.name !== this.unitId) { - this.unitId = currentUnit.name; - - if (this.authToken) { - const decoded: JwtPayload & { workspace: string } = jwtDecode(this.authToken); - const workspace = decoded?.workspace; - if (workspace) { - this.getUnitData(Number(workspace), this.authToken).then(unitData => { - this.setUnitProperties(unitData); - }); - } - } - } - } - } - } - - hasNextUnit(): boolean { - if (!this.bookletData) return false; - - return this.bookletData.currentUnitIndex < this.bookletData.units.length - 1; - } - - hasPreviousUnit(): boolean { - if (!this.bookletData) return false; - - return this.bookletData.currentUnitIndex > 0; - } - ngOnDestroy(): void { this.routerSubscription?.unsubscribe(); this.routerSubscription = null; From ecf5dec52419bd9eaf9fb1bae992da41de64eecd Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:54:58 +0200 Subject: [PATCH 10/12] Schedule job earlier --- apps/backend/src/app/cache/response-cache-scheduler.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/cache/response-cache-scheduler.service.ts b/apps/backend/src/app/cache/response-cache-scheduler.service.ts index 783f28e90..921742787 100644 --- a/apps/backend/src/app/cache/response-cache-scheduler.service.ts +++ b/apps/backend/src/app/cache/response-cache-scheduler.service.ts @@ -20,7 +20,7 @@ export class ResponseCacheSchedulerService { private readonly unitRepository: Repository ) {} - @Cron(CronExpression.EVERY_DAY_AT_4AM) + @Cron(CronExpression.EVERY_DAY_AT_1AM) async cacheAllResponses() { this.logger.log('Starting nightly task to cache all responses'); const startTime = Date.now(); From f4282b207b2ddf7ac56970a746835fb8b0c9aa78 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 20:41:48 +0200 Subject: [PATCH 11/12] Fix variable bundles management --- .../variable-bundle.controller.ts | 35 +- .../services/variable-bundle.service.ts | 36 +- .../variable-bundle-manager.component.html | 11 +- .../variable-bundle-manager.component.ts | 93 +++++- .../services/variable-bundle.service.ts | 316 ++++++------------ 5 files changed, 254 insertions(+), 237 deletions(-) diff --git a/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts b/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts index eda892bd4..6b959f252 100644 --- a/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts +++ b/apps/backend/src/app/admin/variable-bundle/variable-bundle.controller.ts @@ -2,12 +2,15 @@ import { BadRequestException, Body, Controller, + DefaultValuePipe, Delete, Get, NotFoundException, Param, + ParseIntPipe, Post, Put, + Query, UseGuards } from '@nestjs/common'; import { @@ -38,8 +41,8 @@ export class VariableBundleController { @UseGuards(JwtAuthGuard, WorkspaceGuard) @ApiBearerAuth() @ApiOperation({ - summary: 'Get all variable bundles for a workspace', - description: 'Retrieves all variable bundles for a workspace' + summary: 'Get variable bundles for a workspace with pagination', + description: 'Retrieves variable bundles for a workspace with pagination support' }) @ApiParam({ name: 'workspace_id', @@ -49,7 +52,18 @@ export class VariableBundleController { }) @ApiOkResponse({ description: 'The variable bundles have been successfully retrieved.', - type: [VariableBundleDto] + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/VariableBundleDto' } + }, + total: { type: 'number' }, + page: { type: 'number' }, + limit: { type: 'number' } + } + } }) @ApiBadRequestResponse({ description: 'Invalid input data.' @@ -57,10 +71,19 @@ export class VariableBundleController { @ApiNotFoundResponse({ description: 'Workspace not found.' }) - async getVariableBundles(@WorkspaceId() workspaceId: number): Promise { + async getVariableBundles( + @WorkspaceId() workspaceId: number, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number + ): Promise<{ data: VariableBundleDto[]; total: number; page: number; limit: number }> { try { - const variableBundles = await this.variableBundleService.getVariableBundles(workspaceId); - return variableBundles.map(bundle => VariableBundleDto.fromEntity(bundle)); + const result = await this.variableBundleService.getVariableBundles(workspaceId, page, limit); + return { + data: result.data.map(bundle => VariableBundleDto.fromEntity(bundle)), + total: result.total, + page: result.page, + limit: result.limit + }; } catch (error) { if (error instanceof NotFoundException) { throw error; diff --git a/apps/backend/src/app/database/services/variable-bundle.service.ts b/apps/backend/src/app/database/services/variable-bundle.service.ts index 6e1876179..db7ddfb58 100644 --- a/apps/backend/src/app/database/services/variable-bundle.service.ts +++ b/apps/backend/src/app/database/services/variable-bundle.service.ts @@ -14,15 +14,39 @@ export class VariableBundleService { ) {} /** - * Get all variable bundles for a workspace + * Get variable bundles for a workspace with pagination * @param workspaceId The ID of the workspace - * @returns Array of variable bundles + * @param page The page number (1-based) + * @param limit The number of items per page + * @returns Paginated variable bundles with metadata */ - async getVariableBundles(workspaceId: number): Promise { - return this.variableBundleRepository.find({ + async getVariableBundles( + workspaceId: number, + page: number = 1, + limit: number = 10 + ): Promise<{ data: VariableBundle[]; total: number; page: number; limit: number }> { + const validPage = page > 0 ? page : 1; + const validLimit = limit > 0 ? limit : 10; + + const skip = (validPage - 1) * validLimit; + + const total = await this.variableBundleRepository.count({ + where: { workspace_id: workspaceId } + }); + + const data = await this.variableBundleRepository.find({ where: { workspace_id: workspaceId }, - order: { created_at: 'DESC' } + order: { created_at: 'DESC' }, + skip, + take: validLimit }); + + return { + data, + total, + page: validPage, + limit: validLimit + }; } /** @@ -149,8 +173,6 @@ export class VariableBundleService { variableId: string ): Promise { const variableBundle = await this.getVariableBundle(id, workspaceId); - - // Filter out the variable to remove variableBundle.variables = variableBundle.variables.filter( v => !(v.unitName === unitName && v.variableId === variableId) ); diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html index d4b10b45e..eb6e22417 100644 --- a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.html @@ -28,7 +28,7 @@ - + @@ -94,6 +94,15 @@ + + + @if (dataSource.data.length === 0) {

Keine Variablenbündel vorhanden. Erstellen Sie ein neues Variablenbündel mit dem Button oben.

diff --git a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts index 8075fc96a..f920bcc91 100644 --- a/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts +++ b/apps/frontend/src/app/coding/components/variable-bundle-manager/variable-bundle-manager.component.ts @@ -22,9 +22,10 @@ import { MatProgressSpinner } from '@angular/material/progress-spinner'; import { MatCheckbox } from '@angular/material/checkbox'; import { MatAnchor, MatButton, MatIconButton } from '@angular/material/button'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatPaginator, MatPaginatorModule, PageEvent } from '@angular/material/paginator'; import { SearchFilterComponent } from '../../../shared/search-filter/search-filter.component'; import { VariableBundle } from '../../models/coding-job.model'; -import { VariableBundleService } from '../../services/variable-bundle.service'; +import { VariableBundleService, PaginatedBundles } from '../../services/variable-bundle.service'; import { VariableBundleDialogComponent } from '../variable-bundle-dialog/variable-bundle-dialog.component'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialogs/confirm-dialog.component'; @@ -57,7 +58,8 @@ import { ConfirmDialogComponent, ConfirmDialogData } from '../../../shared/dialo MatButton, MatDialogModule, MatTooltipModule, - MatIconButton + MatIconButton, + MatPaginatorModule ] }) export class VariableBundleManagerComponent implements OnInit, AfterViewInit { @@ -70,7 +72,12 @@ export class VariableBundleManagerComponent implements OnInit, AfterViewInit { selection = new SelectionModel(true, []); isLoading = false; + currentPage = 1; + pageSize = 10; + totalItems = 0; + @ViewChild(MatSort) sort!: MatSort; + @ViewChild(MatPaginator) paginator!: MatPaginator; ngOnInit(): void { this.loadVariableBundleGroups(); @@ -78,14 +85,36 @@ export class VariableBundleManagerComponent implements OnInit, AfterViewInit { ngAfterViewInit(): void { this.dataSource.sort = this.sort; + + if (this.paginator) { + this.paginator.pageIndex = this.currentPage - 1; + this.paginator.pageSize = this.pageSize; + this.paginator.length = this.totalItems; + } } - loadVariableBundleGroups(): void { + loadVariableBundleGroups(page: number = this.currentPage, pageSize: number = this.pageSize): void { this.isLoading = true; - this.variableBundleGroupService.getBundleGroups().subscribe({ - next: bundleGroups => { - this.dataSource.data = bundleGroups; + this.variableBundleGroupService.getBundles(page, pageSize).subscribe({ + next: (paginatedResult: PaginatedBundles) => { + // Always set the data first + this.dataSource.data = paginatedResult.bundles; + + if (this.currentFilter) { + this.dataSource.filter = this.currentFilter; + } + + this.totalItems = paginatedResult.total; + this.currentPage = paginatedResult.page; + this.pageSize = paginatedResult.limit; + + if (this.paginator) { + this.paginator.pageIndex = this.currentPage - 1; + this.paginator.pageSize = this.pageSize; + this.paginator.length = this.totalItems; + } + this.isLoading = false; }, error: () => { @@ -95,8 +124,21 @@ export class VariableBundleManagerComponent implements OnInit, AfterViewInit { }); } + onPageChange(event: PageEvent): void { + const page = event.pageIndex + 1; // MatPaginator is zero-based + const pageSize = event.pageSize; + this.loadVariableBundleGroups(page, pageSize); + } + + private currentFilter: string = ''; + applyFilter(filterValue: string): void { - this.dataSource.filter = filterValue.trim().toLowerCase(); + this.currentFilter = filterValue.trim().toLowerCase(); + this.dataSource.filter = this.currentFilter; + if (this.paginator) { + this.paginator.firstPage(); + } + this.loadVariableBundleGroups(1, this.pageSize); } isAllSelected(): boolean { @@ -117,7 +159,15 @@ export class VariableBundleManagerComponent implements OnInit, AfterViewInit { } } - selectRow(row: VariableBundle): void { + selectRow(row: VariableBundle, event?: MouseEvent): void { + if (event && event.target instanceof Element) { + const target = event.target as Element; + if (target.tagName === 'MAT-CHECKBOX' || + target.classList.contains('mat-checkbox') || + target.closest('.mat-checkbox')) { + return; + } + } this.selection.toggle(row); } @@ -202,10 +252,12 @@ export class VariableBundleManagerComponent implements OnInit, AfterViewInit { return; } + const selectedBundles = [...this.selection.selected]; + const selectedCount = selectedBundles.length; const dialogRef = this.dialog.open(ConfirmDialogComponent, { data: { title: 'Variablenbündel löschen', - content: `Sind Sie sicher, dass Sie ${this.selection.selected.length} ausgewählte Variablenbündel löschen möchten?`, + content: `Sind Sie sicher, dass Sie ${selectedCount} ausgewählte Variablenbündel löschen möchten?`, confirmButtonLabel: 'Löschen', showCancel: true } as ConfirmDialogData @@ -213,15 +265,20 @@ export class VariableBundleManagerComponent implements OnInit, AfterViewInit { dialogRef.afterClosed().subscribe(result => { if (result) { - const deletePromises = this.selection.selected.map(bundleGroup => this.variableBundleGroupService.deleteBundle(bundleGroup.id) - ); - - Promise.all(deletePromises).then(() => { - this.loadVariableBundleGroups(); - this.selection.clear(); - this.snackBar.open(`${this.selection.selected.length} Variablenbündel wurden gelöscht`, 'Schließen', { duration: 3000 }); - }).catch(() => { - this.snackBar.open('Fehler beim Löschen der Variablenbündel', 'Schließen', { duration: 3000 }); + import('rxjs').then(({ forkJoin }) => { + const deleteObservables = selectedBundles.map(bundleGroup => this.variableBundleGroupService.deleteBundle(bundleGroup.id)); + + forkJoin(deleteObservables).subscribe({ + next: results => { + this.loadVariableBundleGroups(); + this.selection.clear(); + const successCount = results.filter(success => success).length; + this.snackBar.open(`${successCount} Variablenbündel wurden gelöscht`, 'Schließen', { duration: 3000 }); + }, + error: () => { + this.snackBar.open('Fehler beim Löschen der Variablenbündel', 'Schließen', { duration: 3000 }); + } + }); }); } }); diff --git a/apps/frontend/src/app/coding/services/variable-bundle.service.ts b/apps/frontend/src/app/coding/services/variable-bundle.service.ts index 6e083b634..1799e4c94 100644 --- a/apps/frontend/src/app/coding/services/variable-bundle.service.ts +++ b/apps/frontend/src/app/coding/services/variable-bundle.service.ts @@ -1,10 +1,17 @@ import { Injectable, inject } from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; -import { HttpClient } from '@angular/common/http'; -import { catchError, map, take } from 'rxjs/operators'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { catchError, map } from 'rxjs/operators'; import { SERVER_URL } from '../../injection-tokens'; import { AppService } from '../../services/app.service'; -import { VariableBundle, Variable } from '../models/coding-job.model'; +import { VariableBundle } from '../models/coding-job.model'; + +export interface PaginatedBundles { + bundles: VariableBundle[]; + total: number; + page: number; + limit: number; +} @Injectable({ providedIn: 'root' @@ -15,65 +22,66 @@ export class VariableBundleService { private appService = inject(AppService); private bundlesSubject = new BehaviorSubject([]); - private sampleBundles: VariableBundle[] = [ - { - id: 1, - name: 'Mathematische Fähigkeiten', - description: 'Variablen zur Bewertung mathematischer Fähigkeiten', - createdAt: new Date('2023-01-01'), - updatedAt: new Date('2023-01-15'), - variables: [ - { unitName: 'math101', variableId: 'addition' }, - { unitName: 'math101', variableId: 'subtraction' }, - { unitName: 'math102', variableId: 'multiplication' } - ] - }, - { - id: 2, - name: 'Sprachliche Fähigkeiten', - description: 'Variablen zur Bewertung sprachlicher Fähigkeiten', - createdAt: new Date('2023-02-01'), - updatedAt: new Date('2023-02-15'), - variables: [ - { unitName: 'lang101', variableId: 'grammar' }, - { unitName: 'lang101', variableId: 'vocabulary' }, - { unitName: 'lang102', variableId: 'comprehension' } - ] - } - ]; - constructor() { - this.bundlesSubject.next(this.sampleBundles); + this.bundlesSubject.next([]); } - getBundleGroups(): Observable { + getBundles(page: number = 1, limit: number = 10): Observable { const workspaceId = this.appService.selectedWorkspaceId; if (!workspaceId) { - return of([]); + return of({ + bundles: [], total: 0, page, limit + }); } const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle`; - return this.http.get<{ data: VariableBundle[], total: number }>(url).pipe( + const params = new HttpParams() + .set('page', page.toString()) + .set('limit', limit.toString()); + + interface BackendVariableBundle { + id: number; + workspace_id: number; + name: string; + description?: string; + variables: Array<{ unitName: string; variableId: string }>; + created_at: string; + updated_at: string; + } + + return this.http.get(url, { params }).pipe( map(response => { - const bundles = response.data; + const bundleData = Array.isArray(response) ? response : response.data; + const total = Array.isArray(response) ? bundleData.length : response.total; + const responsePage = Array.isArray(response) ? page : response.page; + const responseLimit = Array.isArray(response) ? limit : response.limit; + + const bundles = bundleData.map(bundle => ({ + id: bundle.id, + name: bundle.name, + description: bundle.description, + createdAt: new Date(bundle.created_at), + updatedAt: new Date(bundle.updated_at), + variables: bundle.variables || [] + } as VariableBundle)); + this.bundlesSubject.next(bundles); - return bundles; + return { + bundles, + total, + page: responsePage, + limit: responseLimit + }; }), - catchError(() => this.bundlesSubject.asObservable().pipe( - take(1) - ) - ) + catchError(error => { + console.error('Error fetching variable bundles:', error); + throw error; + }) ); } - getBundleById(id: number): Observable { - const bundles = this.bundlesSubject.value; - const bundle = bundles.find(b => b.id === id); - return of(bundle); - } - createBundle(bundle: Omit): Observable { const workspaceId = this.appService.selectedWorkspaceId; if (!workspaceId) { @@ -86,23 +94,40 @@ export class VariableBundleService { const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle`; - return this.http.post(url, bundle).pipe( - map(newBundle => { - const updatedBundles = [...this.bundlesSubject.value, newBundle]; - this.bundlesSubject.next(updatedBundles); + interface BackendVariableBundle { + id: number; + workspace_id: number; + name: string; + description?: string; + variables: Array<{ unitName: string; variableId: string }>; + created_at: string; + updated_at: string; + } - return newBundle; - }), - catchError(() => { + const requestPayload = { + name: bundle.name, + description: bundle.description, + variables: bundle.variables + }; + + return this.http.post(url, requestPayload).pipe( + map(response => { const newBundle: VariableBundle = { - ...bundle, - id: this.getNextId() + id: response.id, + name: response.name, + description: response.description, + createdAt: new Date(response.created_at), + updatedAt: new Date(response.updated_at), + variables: response.variables || [] }; - const updatedBundle = [...this.bundlesSubject.value, newBundle]; - this.bundlesSubject.next(updatedBundle); + const updatedBundles = [...this.bundlesSubject.value, newBundle]; + this.bundlesSubject.next(updatedBundles); - return of(newBundle); + return newBundle; + }), + catchError(error => { + throw error; }) ); } @@ -117,13 +142,32 @@ export class VariableBundleService { const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${id}`; const updateData = { - ...bundle, - updatedAt: new Date() + name: bundle.name, + description: bundle.description, + variables: bundle.variables }; - return this.http.put(url, updateData).pipe( - map(updatedBundleGroup => { - // Update the local state with the updated bundle group + interface BackendVariableBundle { + id: number; + workspace_id: number; + name: string; + description?: string; + variables: Array<{ unitName: string; variableId: string }>; + created_at: string; + updated_at: string; + } + + return this.http.put(url, updateData).pipe( + map(response => { + const updatedBundleGroup: VariableBundle = { + id: response.id, + name: response.name, + description: response.description, + createdAt: new Date(response.created_at), + updatedAt: new Date(response.updated_at), + variables: response.variables || [] + }; + const bundles = this.bundlesSubject.value; const index = bundles.findIndex(b => b.id === id); @@ -135,26 +179,8 @@ export class VariableBundleService { return updatedBundleGroup; }), - catchError(() => { - const bundles = this.bundlesSubject.value; - const index = bundles.findIndex(b => b.id === id); - - if (index === -1) { - return of(undefined); - } - - const updatedBundle: VariableBundle = { - ...bundles[index], - ...bundle, - updatedAt: new Date() - }; - - const updatedBundleGroups = [...bundles]; - updatedBundleGroups[index] = updatedBundle; - - this.bundlesSubject.next(updatedBundleGroups); - - return of(updatedBundle); + catchError(error => { + throw error; }) ); } @@ -178,128 +204,8 @@ export class VariableBundleService { return response.success; }), - catchError(() => { - const bundles = this.bundlesSubject.value; - const updatedBundles = bundles.filter(b => b.id !== id); - - if (updatedBundles.length === bundles.length) { - return of(false); - } - - this.bundlesSubject.next(updatedBundles); - - return of(true); - }) - ); - } - - addVariableToBundle(bundleId: number, variable: Variable): Observable { - const workspaceId = this.appService.selectedWorkspaceId; - if (!workspaceId) { - return of(undefined); - } - - const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; - const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${bundleId}/variables`; - - return this.http.post(url, variable).pipe( - map(updatedBundle => { - const bundles = this.bundlesSubject.value; - const index = bundles.findIndex(b => b.id === bundleId); - - if (index !== -1) { - const updatedBundles = [...bundles]; - updatedBundles[index] = updatedBundle; - this.bundlesSubject.next(updatedBundles); - } - - return updatedBundle; - }), - catchError(() => { - const bundles = this.bundlesSubject.value; - const index = bundles.findIndex(b => b.id === bundleId); - - if (index === -1) { - return of(undefined); - } - - const bundle = bundles[index]; - - const variableExists = bundle.variables.some( - v => v.unitName === variable.unitName && v.variableId === variable.variableId - ); - - if (variableExists) { - return of(bundle); - } - - const updatedBundle: VariableBundle = { - ...bundle, - variables: [...bundle.variables, variable as Variable], - updatedAt: new Date() - }; - - const updatedBundles = [...bundles]; - updatedBundles[index] = updatedBundle; - - this.bundlesSubject.next(updatedBundles); - - return of(updatedBundle); - }) - ); - } - - removeVariableFromBundle(groupId: number, variable: Variable): Observable { - const workspaceId = this.appService.selectedWorkspaceId; - if (!workspaceId) { - return of(undefined); - } - - const encodedUnitName = encodeURIComponent((variable as Variable).unitName); - const encodedVariableId = encodeURIComponent((variable as Variable).variableId); - - const baseUrl = this.serverUrl.endsWith('/') ? this.serverUrl.slice(0, -1) : this.serverUrl; - const url = `${baseUrl}/admin/workspace/${workspaceId}/variable-bundle/${groupId}/variables/${encodedUnitName}/${encodedVariableId}`; - - return this.http.delete(url).pipe( - map(updatedBundle => { - const bundles = this.bundlesSubject.value; - const index = bundles.findIndex(group => group.id === groupId); - - if (index !== -1) { - const updatedBundles = [...bundles]; - updatedBundles[index] = updatedBundle; - this.bundlesSubject.next(updatedBundles); - } - - return updatedBundle; - }), - catchError(() => { - const bundles = this.bundlesSubject.value; - const index = bundles.findIndex(group => group.id === groupId); - - if (index === -1) { - return of(undefined); - } - - const bundle = bundles[index]; - - const updatedVariables = bundle.variables.filter( - v => !(v.unitName === (variable as Variable).unitName && v.variableId === (variable as Variable).variableId) - ); - - const updatedBundle: VariableBundle = { - ...bundle, - variables: updatedVariables, - updatedAt: new Date() - }; - - const updatedBundles = [...bundles]; - updatedBundles[index] = updatedBundle; - - this.bundlesSubject.next(updatedBundles); - - return of(updatedBundle); + catchError(error => { + throw error; }) ); } From 5b39ab870d9dace55042fff2928886fd0ecec877 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 5 Aug 2025 22:01:38 +0200 Subject: [PATCH 12/12] Set version to 0.12.1 --- .../app/components/home/home.component.html | 2 +- package-lock.json | 43 +------------------ package.json | 2 +- 3 files changed, 4 insertions(+), 43 deletions(-) diff --git a/apps/frontend/src/app/components/home/home.component.html b/apps/frontend/src/app/components/home/home.component.html index 4a1750499..2e42de375 100755 --- a/apps/frontend/src/app/components/home/home.component.html +++ b/apps/frontend/src/app/components/home/home.component.html @@ -9,7 +9,7 @@ [appTitle]="'Web application for coding'" [introHtml]="'appService.appConfig.introHtml'" [appName]="'IQB-Kodierbox'" - [appVersion]="'0.12.0'" + [appVersion]="'0.12.1'" [userName]="authData.userName" [userLongName]="appService.userProfile.firstName + ' ' + appService.userProfile.lastName" [isUserLoggedIn]="Number(authData.userId) > 0" diff --git a/package-lock.json b/package-lock.json index 64c773713..0d29bf552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "coding-box", - "version": "0.12.0", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "coding-box", - "version": "0.12.0", + "version": "0.12.1", "license": "MIT", "dependencies": { "@angular/animations": "20.0.3", @@ -7569,19 +7569,6 @@ "ioredis": ">=5.0.0" } }, - "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/axios": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-3.1.3.tgz", - "integrity": "sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==", - "license": "MIT", - "optional": true, - "peer": true, - "peerDependencies": { - "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0", - "axios": "^1.3.1", - "rxjs": "^6.0.0 || ^7.0.0" - } - }, "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/terminus": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@nestjs/terminus/-/terminus-10.2.0.tgz", @@ -7653,32 +7640,6 @@ } } }, - "node_modules/@nestjs-modules/ioredis/node_modules/@nestjs/typeorm": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", - "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "uuid": "9.0.1" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", - "reflect-metadata": "^0.1.13 || ^0.2.0", - "rxjs": "^7.2.0", - "typeorm": "^0.3.0" - } - }, - "node_modules/@nestjs-modules/ioredis/node_modules/reflect-metadata": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", - "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", - "license": "Apache-2.0", - "optional": true, - "peer": true - }, "node_modules/@nestjs/axios": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@nestjs/axios/-/axios-4.0.1.tgz", diff --git a/package.json b/package.json index 0ef4f3a00..d2d179cef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "coding-box", - "version": "0.12.0", + "version": "0.12.1", "author": "IQB - Institut zur Qualitätsentwicklung im Bildungswesen", "license": "MIT", "scripts": {