diff --git a/service/app-hub/dashboard/Dockerfile b/service/app-hub/dashboard/Dockerfile new file mode 100644 index 0000000..a7b9aed --- /dev/null +++ b/service/app-hub/dashboard/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20.15.1-alpine AS builder + +WORKDIR /app/builder + +COPY . . + +RUN npm install \ + && npm run build + +FROM nginx:1.19.2 + +WORKDIR /usr/share/nginx/html +COPY --from=builder /app/builder/dist/dashboard/browser ./ +COPY --from=builder /app/builder/nginx.conf /etc/nginx/nginx.conf +COPY --from=builder /app/builder/docker-entrypoint.sh /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/service/app-hub/dashboard/angular.json b/service/app-hub/dashboard/angular.json index 8d46342..d7e2d48 100644 --- a/service/app-hub/dashboard/angular.json +++ b/service/app-hub/dashboard/angular.json @@ -13,6 +13,9 @@ "root": "", "sourceRoot": "src", "prefix": "app", + "i18n": { + "sourceLocale": "de" + }, "architect": { "build": { "builder": "@angular-devkit/build-angular:application", @@ -21,7 +24,8 @@ "index": "src/index.html", "browser": "src/main.ts", "polyfills": [ - "zone.js" + "zone.js", + "@angular/localize/init" ], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", @@ -32,6 +36,8 @@ } ], "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", + "node_modules/@ngxpert/hot-toast/src/styles/styles.css", "src/styles.scss" ], "scripts": [] @@ -42,7 +48,7 @@ { "type": "initial", "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumError": "2MB" }, { "type": "anyComponentStyle", @@ -50,12 +56,24 @@ "maximumError": "4kB" } ], - "outputHashing": "all" + "outputHashing": "all", + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.prod.ts" + } + ] }, "development": { "optimization": false, "extractLicenses": false, - "sourceMap": true + "sourceMap": true, + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.development.ts" + } + ] } }, "defaultConfiguration": "production" @@ -80,7 +98,8 @@ "options": { "polyfills": [ "zone.js", - "zone.js/testing" + "zone.js/testing", + "@angular/localize/init" ], "tsConfig": "tsconfig.spec.json", "inlineStyleLanguage": "scss", @@ -91,6 +110,7 @@ } ], "styles": [ + "@angular/material/prebuilt-themes/azure-blue.css", "src/styles.scss" ], "scripts": [] diff --git a/service/app-hub/dashboard/docker-entrypoint.sh b/service/app-hub/dashboard/docker-entrypoint.sh new file mode 100755 index 0000000..d403580 --- /dev/null +++ b/service/app-hub/dashboard/docker-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -xe +: "${GRAPHQL_ENDPOINT?GRAPHQL_ENDPOINT is not provided}" +: "${GRAPHQL_WS_ENDPOINT?GRAPHQL_WS_ENDPOINT is not provided}" +: "${MS_AUTHENTICATION_CLIENT_ID?MS_AUTHENTICATION_CLIENT_ID is not provided}" +: "${MS_AUTHENTICATION_AUTHORITY?MS_AUTHENTICATION_AUTHORITY is not provided}" +: "${MS_AUTHENTICATION_REDIRECT_URI?MS_AUTHENTICATION_REDIRECT_URI is not provided}" +: "${MS_AUTHENTICATION_POST_LOGOUT_REDIRECT_URI?MS_AUTHENTICATION_POST_LOGOUT_REDIRECT_URI is not provided}" +: "${OTLP_ENDPOINT?OTLP_ENDPOINT is not provided}" +: "${OTLP_SERVICE_NAME?OTLP_SERVICE_NAME is not provided}" +: "${COLLECTOR_SECRET?COLLECTOR_SECRET is not provided}" + +env_vars=("GRAPHQL_ENDPOINT" "GRAPHQL_WS_ENDPOINT" "MS_AUTHENTICATION_CLIENT_ID" "MS_AUTHENTICATION_AUTHORITY" "MS_AUTHENTICATION_REDIRECT_URI" "MS_AUTHENTICATION_POST_LOGOUT_REDIRECT_URI" "OTLP_ENDPOINT" "OTLP_SERVICE_NAME" "COLLECTOR_SECRET") + +ls /usr/share/nginx/html/ + +for var in "${env_vars[@]}" +do + echo "$var ${!var}" + sed -i "s~{{$var}}~${!var}~gi" /usr/share/nginx/html/main*.js +done + +exec "$@" \ No newline at end of file diff --git a/service/app-hub/dashboard/nginx.conf b/service/app-hub/dashboard/nginx.conf new file mode 100644 index 0000000..4f05558 --- /dev/null +++ b/service/app-hub/dashboard/nginx.conf @@ -0,0 +1,44 @@ +user root; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + # Configuration specific to HTTP and affecting all virtual servers + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + + server { + listen 80 default_server; + gzip on; + gzip_disable "MSIE [1-6]\.(?!.*SV1)"; + gzip_proxied any; + gzip_buffers 16 8k; + gzip_types in application/javascript application/x-javascript text/javascript text/xml text/css; + gzip_vary on; + access_log /var/log/nginx/app.access.log; + error_log /var/log/nginx/app.error.log; + root /usr/share/nginx/html; + + location / { + try_files $uri /index.html; + } + } +} \ No newline at end of file diff --git a/service/app-hub/dashboard/package-lock.json b/service/app-hub/dashboard/package-lock.json index 303f77c..bd61da4 100644 --- a/service/app-hub/dashboard/package-lock.json +++ b/service/app-hub/dashboard/package-lock.json @@ -9,13 +9,39 @@ "version": "0.0.0", "dependencies": { "@angular/animations": "^18.1.0", + "@angular/cdk": "^18.1.1", "@angular/common": "^18.1.0", "@angular/compiler": "^18.1.0", "@angular/core": "^18.1.0", "@angular/forms": "^18.1.0", + "@angular/material": "^18.1.1", + "@angular/material-date-fns-adapter": "^18.1.1", "@angular/platform-browser": "^18.1.0", "@angular/platform-browser-dynamic": "^18.1.0", "@angular/router": "^18.1.0", + "@apollo/client": "^3.0.0", + "@azure/msal-angular": "^3.0.22", + "@azure/msal-browser": "^3.19.1", + "@grafana/faro-web-sdk": "^1.15.0", + "@grafana/faro-web-tracing": "^1.15.0", + "@ngneat/dialog": "^5.1.1", + "@ngneat/overview": "^6.0.0", + "@ngxpert/hot-toast": "^3.0.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/auto-instrumentations-web": "^0.45.1", + "@opentelemetry/context-zone": "^1.30.1", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.2", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-document-load": "^0.44.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-web": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "apollo-angular": "^7.0.2", + "date-fns": "^3.6.0", + "echarts": "^5.5.1", + "graphql": "^16", + "graphql-ws": "^5.16.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" @@ -24,16 +50,32 @@ "@angular-devkit/build-angular": "^18.1.1", "@angular/cli": "^18.1.1", "@angular/compiler-cli": "^18.1.0", + "@angular/localize": "^18.1.1", "@types/jasmine": "~5.1.0", + "autoprefixer": "^10.4.19", "jasmine-core": "~5.1.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", "typescript": "~5.5.2" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -191,6 +233,34 @@ } } }, + "node_modules/@angular-devkit/build-angular/node_modules/postcss": { + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@angular-devkit/build-webpack": { "version": "0.1801.1", "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1801.1.tgz", @@ -339,6 +409,22 @@ } } }, + "node_modules/@angular/cdk": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.1.1.tgz", + "integrity": "sha512-IaDjvRUgAoKnEeafrnBX+hjTR+1M3O3fV3AybBCjN4NuiPtuyOJiTMg0cTv6RbluJ/SenbT4MQq3tMpOsa9i4w==", + "dependencies": { + "tslib": "^2.3.0" + }, + "optionalDependencies": { + "parse5": "^7.1.2" + }, + "peerDependencies": { + "@angular/common": "^18.0.0 || ^19.0.0", + "@angular/core": "^18.0.0 || ^19.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "18.1.1", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-18.1.1.tgz", @@ -466,6 +552,108 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/localize": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.1.1.tgz", + "integrity": "sha512-KuUXPWq91XrSsYPvi0tVp+dlG2F+doxXN/FOg+RhUh3X4Qdxy93OWPTMmkWxJ6U9rbXFBKqxfd0+Vnt/BypMmg==", + "dev": true, + "dependencies": { + "@babel/core": "7.24.7", + "@types/babel__core": "7.20.5", + "fast-glob": "3.3.2", + "yargs": "^17.2.1" + }, + "bin": { + "localize-extract": "tools/bundles/src/extract/cli.js", + "localize-migrate": "tools/bundles/src/migrate/cli.js", + "localize-translate": "tools/bundles/src/translate/cli.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0" + }, + "peerDependencies": { + "@angular/compiler": "18.1.1", + "@angular/compiler-cli": "18.1.1" + } + }, + "node_modules/@angular/material": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-18.1.1.tgz", + "integrity": "sha512-9JdUEUZheMMk+Tu8oDLRdI2eXOeI9d2BjEZYkoDif4iB7TCldmcKJyTYXs3kSZz6B53vup/vgKJUPBHLkIDD+A==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/auto-init": "15.0.0-canary.7f224ddd4.0", + "@material/banner": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/button": "15.0.0-canary.7f224ddd4.0", + "@material/card": "15.0.0-canary.7f224ddd4.0", + "@material/checkbox": "15.0.0-canary.7f224ddd4.0", + "@material/chips": "15.0.0-canary.7f224ddd4.0", + "@material/circular-progress": "15.0.0-canary.7f224ddd4.0", + "@material/data-table": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dialog": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/drawer": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/fab": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/floating-label": "15.0.0-canary.7f224ddd4.0", + "@material/form-field": "15.0.0-canary.7f224ddd4.0", + "@material/icon-button": "15.0.0-canary.7f224ddd4.0", + "@material/image-list": "15.0.0-canary.7f224ddd4.0", + "@material/layout-grid": "15.0.0-canary.7f224ddd4.0", + "@material/line-ripple": "15.0.0-canary.7f224ddd4.0", + "@material/linear-progress": "15.0.0-canary.7f224ddd4.0", + "@material/list": "15.0.0-canary.7f224ddd4.0", + "@material/menu": "15.0.0-canary.7f224ddd4.0", + "@material/menu-surface": "15.0.0-canary.7f224ddd4.0", + "@material/notched-outline": "15.0.0-canary.7f224ddd4.0", + "@material/radio": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/segmented-button": "15.0.0-canary.7f224ddd4.0", + "@material/select": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/slider": "15.0.0-canary.7f224ddd4.0", + "@material/snackbar": "15.0.0-canary.7f224ddd4.0", + "@material/switch": "15.0.0-canary.7f224ddd4.0", + "@material/tab": "15.0.0-canary.7f224ddd4.0", + "@material/tab-bar": "15.0.0-canary.7f224ddd4.0", + "@material/tab-indicator": "15.0.0-canary.7f224ddd4.0", + "@material/tab-scroller": "15.0.0-canary.7f224ddd4.0", + "@material/textfield": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/tooltip": "15.0.0-canary.7f224ddd4.0", + "@material/top-app-bar": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/animations": "^18.0.0 || ^19.0.0", + "@angular/cdk": "18.1.1", + "@angular/common": "^18.0.0 || ^19.0.0", + "@angular/core": "^18.0.0 || ^19.0.0", + "@angular/forms": "^18.0.0 || ^19.0.0", + "@angular/platform-browser": "^18.0.0 || ^19.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/material-date-fns-adapter": { + "version": "18.1.1", + "resolved": "https://registry.npmjs.org/@angular/material-date-fns-adapter/-/material-date-fns-adapter-18.1.1.tgz", + "integrity": "sha512-T/l/179K/HhbcaOytL98L1jL3LpmvlIYUTxtYMsMPlztTL9YQPgASWKUHGoLUBWaxnJtYxAzrd011Oo2S5CpcA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": "^18.0.0 || ^19.0.0", + "@angular/material": "18.1.1", + "date-fns": ">2.20.0 <4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "18.1.1", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.1.1.tgz", @@ -521,6 +709,79 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@apollo/client": { + "version": "3.10.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.10.8.tgz", + "integrity": "sha512-UaaFEitRrPRWV836wY2L7bd3HRCfbMie1jlYMcmazFAK23MVhz/Uq7VG1nwbotPb5xzFsw5RF4Wnp2G3dWPM3g==", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.18.0", + "prop-types": "^15.7.2", + "rehackt": "^0.1.0", + "response-iterator": "^0.2.6", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + }, + "peerDependencies": { + "graphql": "^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, + "node_modules/@azure/msal-angular": { + "version": "3.0.22", + "resolved": "https://registry.npmjs.org/@azure/msal-angular/-/msal-angular-3.0.22.tgz", + "integrity": "sha512-4+TOFZQJBNp1/tfjD9PcNYYa0fY1D+kYgM19bpl0S6U0+JdL6IEG+PL37D5fA8KSCVa7lnQWCNJ+tzBBygKdLw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@azure/msal-browser": "^3.19.1", + "rxjs": "^7.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "3.19.1", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-3.19.1.tgz", + "integrity": "sha512-pqYP2gK0GCEa4OxtOqlS+EdFQqhXV6ZuESgSTYWq2ABXyxBVVdd5KNuqgR5SU0OwI2V1YWdFVvLDe1487dyQ0g==", + "dependencies": { + "@azure/msal-common": "14.13.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.13.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-14.13.1.tgz", + "integrity": "sha512-iUp3BYrsRZ4X3EiaZ2fDjNFjmtYMv9rEQd6c1op6ULn0HWk4ACvDmosL6NaBgWOhl1BAblIbd9vmB5/ilF8d4A==", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -2681,6 +2942,85 @@ "node": ">=12" } }, + "node_modules/@grafana/faro-core": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-1.15.0.tgz", + "integrity": "sha512-LkfNnKqIVtnLT/2QxRWttzkZij75cgVmHRGs8g9n4lt3n6Z9SNAGRvGc9oSTHSQZF0EUdQYY3QKyBW+eDq4lxw==", + "dependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/otlp-transformer": "^0.57.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@grafana/faro-web-sdk": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@grafana/faro-web-sdk/-/faro-web-sdk-1.15.0.tgz", + "integrity": "sha512-2wJvqgYCW0trQzPLGKqQYUECIqycaMBLyo7YbG3PmgXEqbVNYQCSKuQ/5RnRNpebWv0uu0ZG/pYmjjyYO8ooUQ==", + "dependencies": { + "@grafana/faro-core": "^1.15.0", + "ua-parser-js": "^1.0.32", + "web-vitals": "^4.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@grafana/faro-web-sdk/node_modules/ua-parser-js": { + "version": "1.0.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.40.tgz", + "integrity": "sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@grafana/faro-web-tracing": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@grafana/faro-web-tracing/-/faro-web-tracing-1.15.0.tgz", + "integrity": "sha512-NP1cebpvQqeSdkMMOMkUJnt3HvShR3aVBSiKnvO9gm0qW09nI2yOLRs1Dyl5aEeC/57EKdyGsHpzpgnfpj2Kng==", + "dependencies": { + "@grafana/faro-web-sdk": "^1.15.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.0", + "@opentelemetry/instrumentation": "^0.57.0", + "@opentelemetry/instrumentation-fetch": "^0.57.0", + "@opentelemetry/instrumentation-xml-http-request": "^0.57.0", + "@opentelemetry/otlp-transformer": "^0.57.1", + "@opentelemetry/resources": "^1.30.0", + "@opentelemetry/sdk-trace-web": "^1.30.0", + "@opentelemetry/semantic-conventions": "^1.28.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@inquirer/checkbox": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-2.4.0.tgz", @@ -3343,6 +3683,758 @@ "win32" ] }, + "node_modules/@material/animation": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/animation/-/animation-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-1GSJaPKef+7HRuV+HusVZHps64cmZuOItDbt40tjJVaikcaZvwmHlcTxRIqzcRoCdt5ZKHh3NoO7GB9Khg4Jnw==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/auto-init": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/auto-init/-/auto-init-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-t7ZGpRJ3ec0QDUO0nJu/SMgLW7qcuG2KqIsEYD1Ej8qhI2xpdR2ydSDQOkVEitXmKoGol1oq4nYSBjTlB65GqA==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/banner": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/banner/-/banner-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-g9wBUZzYBizyBcBQXTIafnRUUPi7efU9gPJfzeGgkynXiccP/vh5XMmH+PBxl5v+4MlP/d4cZ2NUYoAN7UTqSA==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/button": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/base": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/base/-/base-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-I9KQOKXpLfJkP8MqZyr8wZIzdPHrwPjFvGd9zSK91/vPyE4hzHRJc/0njsh9g8Lm9PRYLbifXX+719uTbHxx+A==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/button": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/button/-/button-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-BHB7iyHgRVH+JF16+iscR+Qaic+p7LU1FOLgP8KucRlpF9tTwIxQA6mJwGRi5gUtcG+vyCmzVS+hIQ6DqT/7BA==", + "dependencies": { + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/card": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/card/-/card-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-kt7y9/IWOtJTr3Z/AoWJT3ZLN7CLlzXhx2udCLP9ootZU2bfGK0lzNwmo80bv/pJfrY9ihQKCtuGTtNxUy+vIw==", + "dependencies": { + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/checkbox": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/checkbox/-/checkbox-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-rURcrL5O1u6hzWR+dNgiQ/n89vk6tdmdP3mZgnxJx61q4I/k1yijKqNJSLrkXH7Rto3bM5NRKMOlgvMvVd7UMQ==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/chips": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/chips/-/chips-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-AYAivV3GSk/T/nRIpH27sOHFPaSMrE3L0WYbnb5Wa93FgY8a0fbsFYtSH2QmtwnzXveg+B1zGTt7/xIIcynKdQ==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/checkbox": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "safevalues": "^0.3.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/circular-progress": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/circular-progress/-/circular-progress-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-DJrqCKb+LuGtjNvKl8XigvyK02y36GRkfhMUYTcJEi3PrOE00bwXtyj7ilhzEVshQiXg6AHGWXtf5UqwNrx3Ow==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/progress-indicator": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/data-table": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/data-table/-/data-table-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-/2WZsuBIq9z9RWYF5Jo6b7P6u0fwit+29/mN7rmAZ6akqUR54nXyNfoSNiyydMkzPlZZsep5KrSHododDhBZbA==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/checkbox": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/icon-button": "15.0.0-canary.7f224ddd4.0", + "@material/linear-progress": "15.0.0-canary.7f224ddd4.0", + "@material/list": "15.0.0-canary.7f224ddd4.0", + "@material/menu": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/select": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/density": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/density/-/density-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-o9EXmGKVpiQ6mHhyV3oDDzc78Ow3E7v8dlaOhgaDSXgmqaE8v5sIlLNa/LKSyUga83/fpGk3QViSGXotpQx0jA==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/dialog": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/dialog/-/dialog-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-u0XpTlv1JqWC/bQ3DavJ1JguofTelLT2wloj59l3/1b60jv42JQ6Am7jU3I8/SIUB1MKaW7dYocXjDWtWJakLA==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/button": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/icon-button": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/dom": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/dom/-/dom-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-mQ1HT186GPQSkRg5S18i70typ5ZytfjL09R0gJ2Qg5/G+MLCGi7TAjZZSH65tuD/QGOjel4rDdWOTmYbPYV6HA==", + "dependencies": { + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/drawer": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/drawer/-/drawer-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-qyO0W0KBftfH8dlLR0gVAgv7ZHNvU8ae11Ao6zJif/YxcvK4+gph1z8AO4H410YmC2kZiwpSKyxM1iQCCzbb4g==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/list": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/elevation": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/elevation/-/elevation-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-tV6s4/pUBECedaI36Yj18KmRCk1vfue/JP/5yYRlFNnLMRVISePbZaKkn/BHXVf+26I3W879+XqIGlDVdmOoMA==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/fab": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/fab/-/fab-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-4h76QrzfZTcPdd+awDPZ4Q0YdSqsXQnS540TPtyXUJ/5G99V6VwGpjMPIxAsW0y+pmI9UkLL/srrMaJec+7r4Q==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/feature-targeting": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/feature-targeting/-/feature-targeting-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-SAjtxYh6YlKZriU83diDEQ7jNSP2MnxKsER0TvFeyG1vX/DWsUyYDOIJTOEa9K1N+fgJEBkNK8hY55QhQaspew==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/floating-label": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/floating-label/-/floating-label-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-0KMo5ijjYaEHPiZ2pCVIcbaTS2LycvH9zEhEMKwPPGssBCX7iz5ffYQFk7e5yrQand1r3jnQQgYfHAwtykArnQ==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/focus-ring": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/focus-ring/-/focus-ring-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-Jmg1nltq4J6S6A10EGMZnvufrvU3YTi+8R8ZD9lkSbun0Fm2TVdICQt/Auyi6An9zP66oQN6c31eqO6KfIPsDg==", + "dependencies": { + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0" + } + }, + "node_modules/@material/form-field": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/form-field/-/form-field-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-fEPWgDQEPJ6WF7hNnIStxucHR9LE4DoDSMqCsGWS2Yu+NLZYLuCEecgR0UqQsl1EQdNRaFh8VH93KuxGd2hiPg==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/icon-button": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/icon-button/-/icon-button-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-DcK7IL4ICY/DW+48YQZZs9g0U1kRaW0Wb0BxhvppDMYziHo/CTpFdle4gjyuTyRxPOdHQz5a97ru48Z9O4muTw==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/image-list": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/image-list/-/image-list-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-voMjG2p80XbjL1B2lmF65zO5gEgJOVKClLdqh4wbYzYfwY/SR9c8eLvlYG7DLdFaFBl/7gGxD8TvvZ329HUFPw==", + "dependencies": { + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/layout-grid": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/layout-grid/-/layout-grid-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-veDABLxMn2RmvfnUO2RUmC1OFfWr4cU+MrxKPoDD2hl3l3eDYv5fxws6r5T1JoSyXoaN+oEZpheS0+M9Ure8Pg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/line-ripple": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-f60hVJhIU6I3/17Tqqzch1emUKEcfVVgHVqADbU14JD+oEIz429ZX9ksZ3VChoU3+eejFl+jVdZMLE/LrAuwpg==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/linear-progress": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/linear-progress/-/linear-progress-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-pRDEwPQielDiC9Sc5XhCXrGxP8wWOnAO8sQlMebfBYHYqy5hhiIzibezS8CSaW4MFQFyXmCmpmqWlbqGYRmiyg==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/progress-indicator": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/list": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/list/-/list-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-Is0NV91sJlXF5pOebYAtWLF4wU2MJDbYqztML/zQNENkQxDOvEXu3nWNb3YScMIYJJXvARO0Liur5K4yPagS1Q==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/menu": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/menu/-/menu-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-D11QU1dXqLbh5X1zKlEhS3QWh0b5BPNXlafc5MXfkdJHhOiieb7LC9hMJhbrHtj24FadJ7evaFW/T2ugJbJNnQ==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/list": "15.0.0-canary.7f224ddd4.0", + "@material/menu-surface": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/menu-surface": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/menu-surface/-/menu-surface-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-7RZHvw0gbwppaAJ/Oh5SWmfAKJ62aw1IMB3+3MRwsb5PLoV666wInYa+zJfE4i7qBeOn904xqT2Nko5hY0ssrg==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/notched-outline": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/notched-outline/-/notched-outline-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-Yg2usuKB2DKlKIBISbie9BFsOVuffF71xjbxPbybvqemxqUBd+bD5/t6H1fLE+F8/NCu5JMigho4ewUU+0RCiw==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/floating-label": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/progress-indicator": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/progress-indicator/-/progress-indicator-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-UPbDjE5CqT+SqTs0mNFG6uFEw7wBlgYmh+noSkQ6ty/EURm8lF125dmi4dv4kW0+octonMXqkGtAoZwLIHKf/w==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@material/radio": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/radio/-/radio-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-wR1X0Sr0KmQLu6+YOFKAI84G3L6psqd7Kys5kfb8WKBM36zxO5HQXC5nJm/Y0rdn22ixzsIz2GBo0MNU4V4k1A==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/ripple": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-JqOsWM1f4aGdotP0rh1vZlPZTg6lZgh39FIYHFMfOwfhR+LAikUJ+37ciqZuewgzXB6iiRO6a8aUH6HR5SJYPg==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/rtl": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-UVf14qAtmPiaaZjuJtmN36HETyoKWmsZM/qn1L5ciR2URb8O035dFWnz4ZWFMmAYBno/L7JiZaCkPurv2ZNrGA==", + "dependencies": { + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/segmented-button": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/segmented-button/-/segmented-button-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-LCnVRUSAhELTKI/9hSvyvIvQIpPpqF29BV+O9yM4WoNNmNWqTulvuiv7grHZl6Z+kJuxSg4BGbsPxxb9dXozPg==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/touch-target": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/select": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/select/-/select-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-WioZtQEXRpglum0cMSzSqocnhsGRr+ZIhvKb3FlaNrTaK8H3Y4QA7rVjv3emRtrLOOjaT6/RiIaUMTo9AGzWQQ==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/floating-label": "15.0.0-canary.7f224ddd4.0", + "@material/line-ripple": "15.0.0-canary.7f224ddd4.0", + "@material/list": "15.0.0-canary.7f224ddd4.0", + "@material/menu": "15.0.0-canary.7f224ddd4.0", + "@material/menu-surface": "15.0.0-canary.7f224ddd4.0", + "@material/notched-outline": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/shape": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/shape/-/shape-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-8z8l1W3+cymObunJoRhwFPKZ+FyECfJ4MJykNiaZq7XJFZkV6xNmqAVrrbQj93FtLsECn9g4PjjIomguVn/OEw==", + "dependencies": { + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/slider": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/slider/-/slider-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-QU/WSaSWlLKQRqOhJrPgm29wqvvzRusMqwAcrCh1JTrCl+xwJ43q5WLDfjYhubeKtrEEgGu9tekkAiYfMG7EBw==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/snackbar": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/snackbar/-/snackbar-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-sm7EbVKddaXpT/aXAYBdPoN0k8yeg9+dprgBUkrdqGzWJAeCkxb4fv2B3He88YiCtvkTz2KLY4CThPQBSEsMFQ==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/button": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/icon-button": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/switch": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/switch/-/switch-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-lEDJfRvkVyyeHWIBfoxYjJVl+WlEAE2kZ/+6OqB1FW0OV8ftTODZGhHRSzjVBA1/p4FPuhAtKtoK9jTpa4AZjA==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "safevalues": "^0.3.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/tab": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/tab/-/tab-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-E1xGACImyCLurhnizyOTCgOiVezce4HlBFAI6YhJo/AyVwjN2Dtas4ZLQMvvWWqpyhITNkeYdOchwCC1mrz3AQ==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/focus-ring": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/tab-indicator": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/tab-bar": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/tab-bar/-/tab-bar-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-p1Asb2NzrcECvAQU3b2SYrpyJGyJLQWR+nXTYzDKE8WOpLIRCXap2audNqD7fvN/A20UJ1J8U01ptrvCkwJ4eA==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/tab": "15.0.0-canary.7f224ddd4.0", + "@material/tab-indicator": "15.0.0-canary.7f224ddd4.0", + "@material/tab-scroller": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/tab-indicator": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/tab-indicator/-/tab-indicator-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-h9Td3MPqbs33spcPS7ecByRHraYgU4tNCZpZzZXw31RypjKvISDv/PS5wcA4RmWqNGih78T7xg4QIGsZg4Pk4w==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/tab-scroller": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/tab-scroller/-/tab-scroller-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-LFeYNjQpdXecwECd8UaqHYbhscDCwhGln5Yh+3ctvcEgvmDPNjhKn/DL3sWprWvG8NAhP6sHMrsGhQFVdCWtTg==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/tab": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/textfield": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/textfield/-/textfield-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-AExmFvgE5nNF0UA4l2cSzPghtxSUQeeoyRjFLHLy+oAaE4eKZFrSy0zEpqPeWPQpEMDZk+6Y+6T3cOFYBeSvsw==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/density": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/floating-label": "15.0.0-canary.7f224ddd4.0", + "@material/line-ripple": "15.0.0-canary.7f224ddd4.0", + "@material/notched-outline": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/theme": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/theme/-/theme-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-hs45hJoE9yVnoVOcsN1jklyOa51U4lzWsEnQEuJTPOk2+0HqCQ0yv/q0InpSnm2i69fNSyZC60+8HADZGF8ugQ==", + "dependencies": { + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/tokens": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/tokens/-/tokens-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-r9TDoicmcT7FhUXC4eYMFnt9TZsz0G8T3wXvkKncLppYvZ517gPyD/1+yhuGfGOxAzxTrM66S/oEc1fFE2q4hw==", + "dependencies": { + "@material/elevation": "15.0.0-canary.7f224ddd4.0" + } + }, + "node_modules/@material/tooltip": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/tooltip/-/tooltip-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-8qNk3pmPLTnam3XYC1sZuplQXW9xLn4Z4MI3D+U17Q7pfNZfoOugGr+d2cLA9yWAEjVJYB0mj8Yu86+udo4N9w==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/button": "15.0.0-canary.7f224ddd4.0", + "@material/dom": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/tokens": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "safevalues": "^0.3.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/top-app-bar": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/top-app-bar/-/top-app-bar-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-SARR5/ClYT4CLe9qAXakbr0i0cMY0V3V4pe3ElIJPfL2Z2c4wGR1mTR8m2LxU1MfGKK8aRoUdtfKaxWejp+eNA==", + "dependencies": { + "@material/animation": "15.0.0-canary.7f224ddd4.0", + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/elevation": "15.0.0-canary.7f224ddd4.0", + "@material/ripple": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/shape": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "@material/typography": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/touch-target": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/touch-target/-/touch-target-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-BJo/wFKHPYLGsRaIpd7vsQwKr02LtO2e89Psv0on/p0OephlNIgeB9dD9W+bQmaeZsZ6liKSKRl6wJWDiK71PA==", + "dependencies": { + "@material/base": "15.0.0-canary.7f224ddd4.0", + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/rtl": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@material/typography": { + "version": "15.0.0-canary.7f224ddd4.0", + "resolved": "https://registry.npmjs.org/@material/typography/-/typography-15.0.0-canary.7f224ddd4.0.tgz", + "integrity": "sha512-kBaZeCGD50iq1DeRRH5OM5Jl7Gdk+/NOfKArkY4ksBZvJiStJ7ACAhpvb8MEGm4s3jvDInQFLsDq3hL+SA79sQ==", + "dependencies": { + "@material/feature-targeting": "15.0.0-canary.7f224ddd4.0", + "@material/theme": "15.0.0-canary.7f224ddd4.0", + "tslib": "^2.1.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -3421,6 +4513,33 @@ "win32" ] }, + "node_modules/@ngneat/dialog": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@ngneat/dialog/-/dialog-5.1.1.tgz", + "integrity": "sha512-rVsc49zicax4EHpmRmqDwZ7Wt6i62EPvIOFtMb9WGaWD+/48fkjCys0TaLLY5eUzJRXioEehwhEuvX/FpVqqUw==", + "dependencies": { + "tslib": "2.3.1" + }, + "peerDependencies": { + "@angular/core": ">=17.0.0" + } + }, + "node_modules/@ngneat/dialog/node_modules/tslib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", + "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" + }, + "node_modules/@ngneat/overview": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@ngneat/overview/-/overview-6.0.0.tgz", + "integrity": "sha512-pm4bAEYtnUl8q82dwjh5NN9HF0WTFEI58VtR12izp9Oaa2dtseX82VUArfb4fadmlbHpPMUwXHrsm0ORyWii2A==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@angular/core": ">=17" + } + }, "node_modules/@ngtools/webpack": { "version": "18.1.1", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-18.1.1.tgz", @@ -3437,6 +4556,18 @@ "webpack": "^5.54.0" } }, + "node_modules/@ngxpert/hot-toast": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@ngxpert/hot-toast/-/hot-toast-3.0.0.tgz", + "integrity": "sha512-iMTGGPzNKJWW5c9j3NJvDYmSDld7oWqaRksUoGg0rFKTc92PN1ldwfyL+HEH3ETiomq7aVlNWBye6fn4m+HISw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/core": ">= 18.0.0", + "@ngneat/overview": "6.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3729,6 +4860,572 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.57.2.tgz", + "integrity": "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/auto-instrumentations-web": { + "version": "0.45.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/auto-instrumentations-web/-/auto-instrumentations-web-0.45.1.tgz", + "integrity": "sha512-Q8DhofjNUFaIr4OHoVni96WMGoIijr0tKCtPe6c0Ih07/PZVWXBWT6PLpwOu0yusTzgc0TMuY5LOBqUVd6Y58w==", + "dependencies": { + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/instrumentation-document-load": "^0.44.1", + "@opentelemetry/instrumentation-fetch": "^0.57.1", + "@opentelemetry/instrumentation-user-interaction": "^0.44.1", + "@opentelemetry/instrumentation-xml-http-request": "^0.57.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0", + "zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0" + } + }, + "node_modules/@opentelemetry/context-zone": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone/-/context-zone-1.30.1.tgz", + "integrity": "sha512-N6CACt5sxXD6XzS2jJPqstNJZ/QFqeW56IiKfHb6hYOelCXVvYfxheF7byAeRXa7+N8rmXUP7aRdupALXP5hdQ==", + "dependencies": { + "@opentelemetry/context-zone-peer-dep": "1.30.1", + "zone.js": "^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/context-zone-peer-dep": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-zone-peer-dep/-/context-zone-peer-dep-1.30.1.tgz", + "integrity": "sha512-8oJQR+MBblY5WGOm26AOBLogN+FoO26QEZssH9WJd1qSD7ABMefq9qyGyN9xilFFl2ABPHgcMzkfYb8vx9JBEA==", + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0", + "zone.js": "^0.10.2 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^0.14.0 || ^0.15.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.0.tgz", + "integrity": "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.57.2.tgz", + "integrity": "sha512-sB/gkSYFu+0w2dVQ0PWY9fAMl172PKMZ/JrHkkW8dmjCL0CYkmXeE+ssqIL/yBUTPOvpLIpenX5T9RwXRBW/3g==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/otlp-exporter-base": "0.57.2", + "@opentelemetry/otlp-transformer": "0.57.2", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", + "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@types/shimmer": "^1.2.0", + "import-in-the-middle": "^1.8.1", + "require-in-the-middle": "^7.1.1", + "semver": "^7.5.2", + "shimmer": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-document-load": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-document-load/-/instrumentation-document-load-0.44.1.tgz", + "integrity": "sha512-+Cu5K9TmW1LG0NvHi9SU8hDP4JJZiqWY5w+QHAXiozDmiET+cNjPmpbNOtg7QIuny7vo2nsahrM+JtkZONkv+w==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/sdk-trace-web": "^1.15.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-document-load/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-document-load/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-fetch/-/instrumentation-fetch-0.57.2.tgz", + "integrity": "sha512-LF/lH9xpRTuGPdxta6Eiezw91DFm0A9SMux1vslNwSgL4jiB+q1fQ/8CRv7e5UNh7y/hit4LAdGPoH+f0wfTTQ==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/instrumentation": "0.57.2", + "@opentelemetry/sdk-trace-web": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-fetch/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-user-interaction": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-user-interaction/-/instrumentation-user-interaction-0.44.1.tgz", + "integrity": "sha512-1T93GDxNiyq8c010/+E8tSjnnbctaJQIR8TELHRPWwbbJ/Up1+h2LDbC32Ztw/RfZ2vcWZVA16wY5JHoUiEwiQ==", + "dependencies": { + "@opentelemetry/core": "^1.8.0", + "@opentelemetry/instrumentation": "^0.57.1", + "@opentelemetry/sdk-trace-web": "^1.8.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0", + "zone.js": "^0.11.4 || ^0.13.0 || ^0.14.0 || ^0.15.0" + } + }, + "node_modules/@opentelemetry/instrumentation-user-interaction/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-user-interaction/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-xml-http-request/-/instrumentation-xml-http-request-0.57.2.tgz", + "integrity": "sha512-9niBJT2egcytOqDEfA27xc6TIqlOTEzQYNli2lWuw+K7TeO7KcyYzmIeS/S6BYLulOsOWtvlE6CDDxXg+GUepw==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/instrumentation": "0.57.2", + "@opentelemetry/sdk-trace-web": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-xml-http-request/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.57.2.tgz", + "integrity": "sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/otlp-transformer": "0.57.2" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.57.2.tgz", + "integrity": "sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/sdk-logs": "0.57.2", + "@opentelemetry/sdk-metrics": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "protobufjs": "^7.3.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.30.1.tgz", + "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.57.2", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.57.2.tgz", + "integrity": "sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==", + "dependencies": { + "@opentelemetry/api-logs": "0.57.2", + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-1.30.1.tgz", + "integrity": "sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.30.1.tgz", + "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/resources": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-1.30.1.tgz", + "integrity": "sha512-AUo2e+1uyTGMB36VlbvBqnCogVzQhpC7dRcVVdCrt+cFHLpFRRJcd45J2obGTgs0XiAwNLyq5bhkW3JF2NZA+A==", + "dependencies": { + "@opentelemetry/core": "1.30.1", + "@opentelemetry/sdk-trace-base": "1.30.1", + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", + "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", + "dependencies": { + "@opentelemetry/semantic-conventions": "1.28.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.28.0.tgz", + "integrity": "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.30.0.tgz", + "integrity": "sha512-4VlGgo32k2EQ2wcCY3vEU28A0O13aOtHz3Xt2/2U5FAh9EfhD6t6DqL5Z6yAnRCntbTFDU4YfbpyzSlHNWycPw==", + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3739,6 +5436,60 @@ "node": ">=14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", @@ -4101,6 +5852,47 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -4250,7 +6042,6 @@ "version": "20.14.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -4312,6 +6103,11 @@ "@types/send": "*" } }, + "node_modules/@types/shimmer": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", + "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==" + }, "node_modules/@types/sockjs": { "version": "0.3.36", "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", @@ -4494,6 +6290,50 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -4535,10 +6375,9 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", - "dev": true, + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "bin": { "acorn": "bin/acorn" }, @@ -4550,7 +6389,6 @@ "version": "1.9.5", "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", - "dev": true, "peerDependencies": { "acorn": "^8" } @@ -4709,6 +6547,12 @@ "node": ">=4" } }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4734,6 +6578,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/apollo-angular": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/apollo-angular/-/apollo-angular-7.0.2.tgz", + "integrity": "sha512-OBj+KnVfGsJ1wdsOj5CENyjBnd85vseAlgqW1kG4tvgz0QPgPlUHx5KBKhwlf2hWZceNQQcXoWQmnXxzdZbWDg==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "@angular/core": "^17.0.0 || ^18.0.0", + "@apollo/client": "^3.0.0", + "graphql": "^15.0.0 || ^16.0.0", + "rxjs": "^6.0.0 || ^7.0.0" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5185,6 +7052,15 @@ "node": ">=6" } }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001642", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001642.tgz", @@ -5267,6 +7143,11 @@ "node": ">=6.0" } }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -5466,10 +7347,13 @@ "dev": true }, "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } }, "node_modules/common-path-prefix": { "version": "3.0.0", @@ -5925,6 +7809,15 @@ "integrity": "sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==", "dev": true }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/date-format": { "version": "4.0.14", "resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz", @@ -5938,7 +7831,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -6072,6 +7964,18 @@ "integrity": "sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==", "dev": true }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true + }, "node_modules/dns-packet": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", @@ -6157,6 +8061,20 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true }, + "node_modules/echarts": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.5.1.tgz", + "integrity": "sha512-Fce8upazaAXUVUVsjgV6mBnGuqgO+JNDlcgF79Dksy4+wgGpQB2lmYoO4TSweFg/mZITdpGHomw/cNBJZj1icA==", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6164,9 +8082,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.829", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.829.tgz", - "integrity": "sha512-5qp1N2POAfW0u1qGAxXEtz6P7bO1m6gpZr5hdf5ve6lxpLM7MpiM4jIPz7xcrNlClQMafbyUDDWjlIQZ1Mw0Rw==", + "version": "1.4.830", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.830.tgz", + "integrity": "sha512-TrPKKH20HeN0J1LHzsYLs2qwXrp8TF4nHdu4sq61ozGbzMpWhI7iIOPYPPkxeq1azMT9PZ8enPFcftbs/Npcjg==", "dev": true }, "node_modules/emoji-regex": { @@ -6275,7 +8193,7 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.12" }, @@ -6915,7 +8833,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -7067,6 +8984,42 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.0.tgz", + "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", + "workspaces": [ + "website" + ], + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -7122,7 +9075,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -7130,6 +9082,14 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -7473,6 +9433,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.13.1.tgz", + "integrity": "sha512-k2V9wNm9B+ysuelDTHjI9d5KPc4l8zAZTGqj+pcynvWkypZd857ryzN8jNC7Pg2YZXNMJcHRPpaDyCBbNyVRpA==", + "dependencies": { + "acorn": "^8.14.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^1.2.2", + "module-details-from-path": "^1.0.3" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -7561,7 +9532,6 @@ "version": "2.15.0", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.0.tgz", "integrity": "sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==", - "dev": true, "dependencies": { "hasown": "^2.0.2" }, @@ -7943,8 +9913,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "4.1.0", @@ -8390,6 +10359,16 @@ "node": ">=4" } }, + "node_modules/less/node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/less/node_modules/semver": { "version": "5.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", @@ -8427,6 +10406,15 @@ } } }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -8802,6 +10790,22 @@ "node": ">=8.0" } }, + "node_modules/long": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.1.tgz", + "integrity": "sha512-ka87Jz3gcx/I7Hal94xaN2tZEOPoUOEVftkQqZx2EeQRN7LGdfLlI3FvZ+7WDplm+vK2Urx9ULrvSowtdCieng==" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -9201,6 +11205,11 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/module-details-from-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", + "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -9213,8 +11222,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/msgpackr": { "version": "1.11.0", @@ -9269,6 +11277,17 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", @@ -9662,11 +11681,19 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", @@ -9748,6 +11775,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optimism": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.0.tgz", + "integrity": "sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ==", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.4.3", + "tslib": "^2.3.0" + } + }, + "node_modules/optimism/node_modules/@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -10044,7 +12093,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, + "devOptional": true, "dependencies": { "entities": "^4.4.0" }, @@ -10117,8 +12166,7 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { "version": "1.11.1", @@ -10179,13 +12227,21 @@ } }, "node_modules/pify": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", - "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", "dev": true, - "optional": true, "engines": { - "node": ">=6" + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" } }, "node_modules/piscina": { @@ -10213,9 +12269,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "dev": true, "funding": [ { @@ -10233,13 +12289,96 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/postcss-loader": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/postcss-loader/-/postcss-loader-8.1.1.tgz", @@ -10336,6 +12475,25 @@ "postcss": "^8.1.0" } }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, "node_modules/postcss-selector-parser": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", @@ -10389,6 +12547,39 @@ "node": ">=10" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/protobufjs": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", + "integrity": "sha512-mRUWCc3KUU4w1jU8sGxICXH/gNS94DvI1gxqDvBzhj1JpcsimQkYiOJfwsPUykUI5ZaspFbSgmBLER8IrQ3tqw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -10501,6 +12692,20 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -10622,6 +12827,23 @@ "jsesc": "bin/jsesc" } }, + "node_modules/rehackt": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.1.0.tgz", + "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -10640,6 +12862,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "7.5.2", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.5.2.tgz", + "integrity": "sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3", + "resolve": "^1.22.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, "node_modules/requires-port": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", @@ -10650,7 +12885,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -10711,6 +12945,14 @@ "node": ">=0.10.0" } }, + "node_modules/response-iterator": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz", + "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/restore-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", @@ -10878,6 +13120,11 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/safevalues": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/safevalues/-/safevalues-0.3.4.tgz", + "integrity": "sha512-LRneZZRXNgjzwG4bDQdOTSbze3fHm1EAKN/8bePxnlEZiBmkYEDggaHbuvHI9/hoqHbGfsEA7tWS9GhYHZBBsw==" + }, "node_modules/sass": { "version": "1.77.6", "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.6.tgz", @@ -11001,7 +13248,6 @@ "version": "7.6.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, "bin": { "semver": "bin/semver.js" }, @@ -11233,6 +13479,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shimmer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -11699,6 +13950,72 @@ "node": ">=6" } }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -11715,7 +14032,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -11727,11 +14043,59 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true, "engines": { "node": ">=0.10" } }, + "node_modules/tailwindcss": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", + "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", + "dev": true, + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -11910,6 +14274,33 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/thingies": { "version": "1.21.0", "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", @@ -11995,6 +14386,23 @@ "tree-kill": "cli.js" } }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true + }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tslib": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", @@ -12093,8 +14501,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.0", @@ -12391,6 +14798,11 @@ "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", "dev": true }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" + }, "node_modules/webpack": { "version": "5.92.1", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz", @@ -12950,6 +15362,18 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -13030,10 +15454,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" + }, + "node_modules/zen-observable-ts": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", + "dependencies": { + "zen-observable": "0.8.15" + } + }, "node_modules/zone.js": { "version": "0.14.8", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.8.tgz", "integrity": "sha512-48uh7MnVp4/OQDuCHeFdXw5d8xwPqFTvlHgPJ1LBFb5GaustLSZV+YUH0to5ygNyGpqTsjpbpt141U/j3pCfqQ==" + }, + "node_modules/zrender": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.0.tgz", + "integrity": "sha512-uzgraf4njmmHAbEUxMJ8Oxg+P3fT04O+9p7gY+wJRVxo8Ge+KmYv0WJev945EH4wFuc4OY2NLXz46FZrWS9xJg==", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" } } } diff --git a/service/app-hub/dashboard/package.json b/service/app-hub/dashboard/package.json index d49d3fe..33e2cc8 100644 --- a/service/app-hub/dashboard/package.json +++ b/service/app-hub/dashboard/package.json @@ -4,20 +4,46 @@ "scripts": { "ng": "ng", "start": "ng serve", - "build": "ng build", + "build": "ng build -c production", "watch": "ng build --watch --configuration development", "test": "ng test" }, "private": true, "dependencies": { "@angular/animations": "^18.1.0", + "@angular/cdk": "^18.1.1", "@angular/common": "^18.1.0", "@angular/compiler": "^18.1.0", "@angular/core": "^18.1.0", "@angular/forms": "^18.1.0", + "@angular/material": "^18.1.1", + "@angular/material-date-fns-adapter": "^18.1.1", "@angular/platform-browser": "^18.1.0", "@angular/platform-browser-dynamic": "^18.1.0", "@angular/router": "^18.1.0", + "@apollo/client": "^3.0.0", + "@azure/msal-angular": "^3.0.22", + "@azure/msal-browser": "^3.19.1", + "@grafana/faro-web-sdk": "^1.15.0", + "@grafana/faro-web-tracing": "^1.15.0", + "@ngneat/dialog": "^5.1.1", + "@ngneat/overview": "^6.0.0", + "@ngxpert/hot-toast": "^3.0.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/auto-instrumentations-web": "^0.45.1", + "@opentelemetry/context-zone": "^1.30.1", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.57.2", + "@opentelemetry/instrumentation": "^0.57.2", + "@opentelemetry/instrumentation-document-load": "^0.44.1", + "@opentelemetry/resources": "^1.30.1", + "@opentelemetry/sdk-trace-web": "^1.30.1", + "@opentelemetry/semantic-conventions": "^1.30.0", + "apollo-angular": "^7.0.2", + "date-fns": "^3.6.0", + "echarts": "^5.5.1", + "graphql": "^16", + "graphql-ws": "^5.16.0", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.14.3" @@ -26,13 +52,17 @@ "@angular-devkit/build-angular": "^18.1.1", "@angular/cli": "^18.1.1", "@angular/compiler-cli": "^18.1.0", + "@angular/localize": "^18.1.1", "@types/jasmine": "~5.1.0", + "autoprefixer": "^10.4.19", "jasmine-core": "~5.1.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", "karma-jasmine": "~5.1.0", "karma-jasmine-html-reporter": "~2.1.0", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", "typescript": "~5.5.2" } } diff --git a/service/app-hub/dashboard/src/app/app.component.html b/service/app-hub/dashboard/src/app/app.component.html index 36093e1..3742545 100644 --- a/service/app-hub/dashboard/src/app/app.component.html +++ b/service/app-hub/dashboard/src/app/app.component.html @@ -1,336 +1,3 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - - + + + diff --git a/service/app-hub/dashboard/src/app/app.component.ts b/service/app-hub/dashboard/src/app/app.component.ts index 3a1913a..85156f0 100644 --- a/service/app-hub/dashboard/src/app/app.component.ts +++ b/service/app-hub/dashboard/src/app/app.component.ts @@ -1,13 +1,19 @@ import { Component } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { AppShellComponent } from './core/components/app-shell/app-shell.component'; +import { MsalService } from '@azure/msal-angular'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [AppShellComponent, RouterOutlet], templateUrl: './app.component.html', - styleUrl: './app.component.scss' + styleUrl: './app.component.scss', }) export class AppComponent { title = 'dashboard'; + + constructor(private readonly authService: MsalService) { + this.authService.handleRedirectObservable().subscribe(); + } } diff --git a/service/app-hub/dashboard/src/app/app.config.ts b/service/app-hub/dashboard/src/app/app.config.ts index a1e7d6f..f9913a9 100644 --- a/service/app-hub/dashboard/src/app/app.config.ts +++ b/service/app-hub/dashboard/src/app/app.config.ts @@ -1,8 +1,47 @@ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { + APP_INITIALIZER, + ApplicationConfig, + ErrorHandler, + importProvidersFrom, + provideZoneChangeDetection, +} from '@angular/core'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { MsAuthenticationModule } from './core/components/ms-authentication/ms-authentication.module'; +import { environment } from '../environments/environment'; +import { provideHttpClient } from '@angular/common/http'; +import { graphqlProvider } from './graphql.provider'; +import { provideHotToastConfig } from '@ngxpert/hot-toast'; +import { faroInitializer } from './core/telemetry/faro-initializer'; +import { GlobalErrorHandler } from './core/telemetry/global-error-handler'; export const appConfig: ApplicationConfig = { - providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes)] + providers: [ + { + provide: APP_INITIALIZER, + useFactory: faroInitializer, + deps: [], + multi: true, + }, + { + provide: ErrorHandler, + useClass: GlobalErrorHandler + }, + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideAnimationsAsync(), + importProvidersFrom( + MsAuthenticationModule.forRoot({ + clientId: environment.msAuth.clientId, + authority: environment.msAuth.authority, + redirectUri: environment.msAuth.redirectUri, + postLogoutRedirectUri: environment.msAuth.postLogoutRedirectUri, + }) + ), + provideHttpClient(), + graphqlProvider, + provideHotToastConfig(), + ], }; diff --git a/service/app-hub/dashboard/src/app/app.routes.ts b/service/app-hub/dashboard/src/app/app.routes.ts index dc39edb..8f92bd3 100644 --- a/service/app-hub/dashboard/src/app/app.routes.ts +++ b/service/app-hub/dashboard/src/app/app.routes.ts @@ -1,3 +1,57 @@ import { Routes } from '@angular/router'; +import { MsalGuard } from '@azure/msal-angular'; +import { roleGuard } from './core/components/ms-authentication/role.guard'; -export const routes: Routes = []; +export const routes: Routes = [ + { + path: 'control-center', + loadChildren: () => + import('./pages/control-center/dashboard-control-center.module').then( + (m) => m.DashboardControlCenterModule + ), + canActivate: [MsalGuard], + data: { + title: 'Dashboard Leitstelle', + }, + }, + { + path: 'managers', + loadChildren: () => + import( + './pages/manager/avicenna-web-feature-dashboard-manager.module' + ).then((m) => m.AvicennaWebFeatureDashboardManagerModule), + canActivate: [MsalGuard, roleGuard], + data: { + title: 'Dashboard Manager', + roles: ['manager', 'admin'], + }, + }, + { + path: 'accounting', + loadChildren: () => + import('./pages/accounting/dashboard-accounting.module').then( + (m) => m.DashboardAccountingModule + ), + canActivate: [MsalGuard, roleGuard], + data: { + title: 'Abrechnung', + roles: ['manager', 'admin'], + }, + }, + { + path: 'tickets', + loadChildren: () => + import('./pages/ticket-system/ticket-system.module').then( + (m) => m.TicketSystemModule + ), + canActivate: [MsalGuard], + data: { + title: 'Ticketsystem', + }, + }, + { + path: '', + pathMatch: 'full', + redirectTo: 'control-center', + }, +]; diff --git a/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.html b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.html new file mode 100644 index 0000000..9de9513 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.html @@ -0,0 +1,52 @@ + + + Navigation + + Dashboard Leitstelle + Dashboard Manager + Abrechnung + Ticket System + + + + + + {{ title$ | async }} + + + + + diff --git a/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.scss b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.scss new file mode 100644 index 0000000..631c2d7 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.scss @@ -0,0 +1,3 @@ +:root { + --mat-toolbar-container-background-color: #17253D; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.spec.ts b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.spec.ts new file mode 100644 index 0000000..440f78c --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppShellComponent } from './app-shell.component'; + +describe('AppShellComponent', () => { + let component: AppShellComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AppShellComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(AppShellComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.ts b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.ts new file mode 100644 index 0000000..ab18e2e --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/app-shell/app-shell.component.ts @@ -0,0 +1,71 @@ +import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; +import { AsyncPipe } from '@angular/common'; +import { Component } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatToolbarModule } from '@angular/material/toolbar'; +import { + ActivatedRoute, + NavigationEnd, + Router, + RouterLink, + RouterLinkActive, + RouterOutlet, +} from '@angular/router'; +import { Observable, filter, map, shareReplay } from 'rxjs'; +import { AvatarComponent } from '../avatar/avatar.component'; +import { RoleDirective } from '../ms-authentication/role.directive'; + +@Component({ + selector: 'app-app-shell', + standalone: true, + imports: [ + MatButtonModule, + MatIconModule, + MatToolbarModule, + MatSidenavModule, + MatListModule, + AvatarComponent, + RouterOutlet, + RouterLink, + RouterLinkActive, + AsyncPipe, + RoleDirective, + ], + templateUrl: './app-shell.component.html', + styleUrl: './app-shell.component.scss', +}) +export class AppShellComponent { + isHandset$: Observable; + + title$: Observable; + + constructor( + private readonly breakpointObserver: BreakpointObserver, + private readonly router: Router, + private readonly activatedRoute: ActivatedRoute + ) { + this.isHandset$ = this.breakpointObserver.observe(Breakpoints.Handset).pipe( + map((result) => result.matches), + shareReplay() + ); + + this.title$ = this.router.events.pipe( + filter((event) => event instanceof NavigationEnd), + map(() => { + let child = this.activatedRoute.firstChild; + while (child) { + if (child.snapshot.data?.['title']) { + return child.snapshot.data['title']; + } else if (child.firstChild) { + child = child.firstChild; + } else { + return 'Avicenna Dashboard'; + } + } + }) + ); + } +} diff --git a/service/app-hub/dashboard/src/app/core/components/avatar/avatar.component.ts b/service/app-hub/dashboard/src/app/core/components/avatar/avatar.component.ts new file mode 100644 index 0000000..bed40fb --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/avatar/avatar.component.ts @@ -0,0 +1,38 @@ +import { AsyncPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatMenuModule } from '@angular/material/menu'; +import { map } from 'rxjs'; +import { AuthService } from '../ms-authentication/auth.service'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-avatar', + template: `
+ {{ userInitials$ | async }} +
+ + + `, + imports: [AsyncPipe, MatMenuModule], +}) +export class AvatarComponent { + private authService = inject(AuthService); + + userInitials$ = this.authService.activeAccount$.pipe( + map((account) => + account?.name + ?.split(' ') + .slice(0, 2) + .map((word) => word.at(0)) + .join('') + ) + ); + + logout() { + this.authService.logout(); + } +} diff --git a/service/app-hub/dashboard/src/app/core/components/confirm-dialog/confirm-dialog.component.ts b/service/app-hub/dashboard/src/app/core/components/confirm-dialog/confirm-dialog.component.ts new file mode 100644 index 0000000..9ac16da --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/confirm-dialog/confirm-dialog.component.ts @@ -0,0 +1,25 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { DialogCloseDirective, DialogRef } from '@ngneat/dialog'; + +export interface ConfirmDialogData { + title: string; + body: string; +} + +@Component({ + template: `
+
{{ ref.data.title }}
+
{{ ref.data.body }}
+
+ + +
+
`, + standalone: true, + imports: [MatButtonModule, DialogCloseDirective], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ConfirmDialogComponent { + ref: DialogRef = inject(DialogRef); +} diff --git a/service/app-hub/dashboard/src/app/core/components/ms-authentication/auth.service.ts b/service/app-hub/dashboard/src/app/core/components/ms-authentication/auth.service.ts new file mode 100644 index 0000000..338f6b6 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/ms-authentication/auth.service.ts @@ -0,0 +1,66 @@ +import { Injectable, OnDestroy, inject } from '@angular/core'; +import { MsalBroadcastService, MsalService } from '@azure/msal-angular'; +import { AccountInfo, InteractionStatus } from '@azure/msal-browser'; +import { + BehaviorSubject, + Subscription, + filter, + map, + shareReplay, + tap, +} from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class AuthService implements OnDestroy { + private subscriptions = new Subscription(); + private account$ = new BehaviorSubject(undefined); + + activeAccount$ = this.account$.pipe( + tap((account) => + this.msalService.instance.setActiveAccount(account ?? null) + ) + ); + roles$ = this.activeAccount$.pipe( + filter(Boolean), + map((account) => account?.idTokenClaims?.roles), + shareReplay(1), + ); + + msalService = inject(MsalService); + msalBroadcastService = inject(MsalBroadcastService); + + constructor() { + this.subscriptions.add( + this.msalBroadcastService.inProgress$ + .pipe( + filter((status) => status === InteractionStatus.None), + tap(() => this.setActiveAccount()) + ) + .subscribe() + ); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + logout(): void { + this.msalService.logoutRedirect(); + } + + private setActiveAccount(): void { + const activeAccount = this.msalService.instance.getActiveAccount(); + + if ( + !activeAccount && + this.msalService.instance.getAllAccounts().length > 0 + ) { + const accounts = this.msalService.instance.getAllAccounts(); + this.account$.next(accounts.at(0)); + } else if (activeAccount) { + this.account$.next(activeAccount); + } + } +} diff --git a/service/app-hub/dashboard/src/app/core/components/ms-authentication/ms-authentication.module.ts b/service/app-hub/dashboard/src/app/core/components/ms-authentication/ms-authentication.module.ts new file mode 100644 index 0000000..fe5310e --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/ms-authentication/ms-authentication.module.ts @@ -0,0 +1,122 @@ +import { HTTP_INTERCEPTORS } from '@angular/common/http'; +import { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core'; +import { + MSAL_GUARD_CONFIG, + MSAL_INSTANCE, + MSAL_INTERCEPTOR_CONFIG, + MsalBroadcastService, + MsalGuard, + MsalGuardConfiguration, + MsalInterceptor, + MsalInterceptorConfiguration, + MsalModule, + MsalRedirectComponent, + MsalService, +} from '@azure/msal-angular'; +import { + BrowserCacheLocation, + IPublicClientApplication, + InteractionType, + PublicClientApplication, +} from '@azure/msal-browser'; +import { AuthService } from './auth.service'; + +export interface MSAuthenticationConfig { + clientId: string; + authority: string | undefined; + redirectUri: string; + postLogoutRedirectUri: string; +} + +const MS_AUTH_CONFIG = new InjectionToken('MS_AUTH_CONFIG'); + +/** + * Here we pass the configuration parameters to create an MSAL instance. + * For more info, visit: https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-angular/docs/v2-docs/configuration.md + */ +export function MSALInstanceFactory( + config: MSAuthenticationConfig +): IPublicClientApplication { + return new PublicClientApplication({ + // MSAL Configuration + auth: { + clientId: config.clientId, + authority: config.authority, + redirectUri: config.redirectUri, + postLogoutRedirectUri: config.postLogoutRedirectUri, + }, + cache: { + cacheLocation: BrowserCacheLocation.LocalStorage, + storeAuthStateInCookie: true, // set to true for IE 11 + }, + system: { + loggerOptions: { + loggerCallback: console.log, + piiLoggingEnabled: false, + }, + }, + }); +} + +/** + * Set your default interaction type for MSALGuard here. If you have any + * additional scopes you want the user to consent upon login, add them here as well. + */ +export function MSALGuardConfigFactory(): MsalGuardConfiguration { + return { + interactionType: InteractionType.Redirect, + }; +} + +export function MSALInterceptorConfigFactory(): MsalInterceptorConfiguration { + const protectedResourceMap = new Map>(); + protectedResourceMap.set('https://graph.microsoft.com/v1.0/me', [ + 'user.read', + ]); + + return { + interactionType: InteractionType.Redirect, + protectedResourceMap, + }; +} + +@NgModule({ + imports: [MsalModule], + bootstrap: [MsalRedirectComponent], +}) +export class MsAuthenticationModule { + public static forRoot( + config: MSAuthenticationConfig + ): ModuleWithProviders { + return { + ngModule: MsAuthenticationModule, + providers: [ + { + provide: MS_AUTH_CONFIG, + useValue: config, + }, + { + provide: MSAL_INSTANCE, + useFactory: MSALInstanceFactory, + deps: [MS_AUTH_CONFIG], + }, + { + provide: MSAL_GUARD_CONFIG, + useFactory: MSALGuardConfigFactory, + }, + { + provide: MSAL_INTERCEPTOR_CONFIG, + useFactory: MSALInterceptorConfigFactory, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: MsalInterceptor, + multi: true, + }, + MsalService, + MsalGuard, + MsalBroadcastService, + ], + }; + } +} diff --git a/service/app-hub/dashboard/src/app/core/components/ms-authentication/role.directive.ts b/service/app-hub/dashboard/src/app/core/components/ms-authentication/role.directive.ts new file mode 100644 index 0000000..acc49ec --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/ms-authentication/role.directive.ts @@ -0,0 +1,66 @@ +import { + Directive, + Input, + OnDestroy, + TemplateRef, + ViewContainerRef, + inject +} from '@angular/core'; +import { + Subject, + Subscription, + combineLatest, + distinctUntilChanged, + tap, +} from 'rxjs'; +import { AuthService } from './auth.service'; + +@Directive({ + selector: '[dksRole]', + standalone: true, +}) +export class RoleDirective implements OnDestroy { + private hasView = false; + private subscription = new Subscription(); + private allowedRoles$ = new Subject(); + + templateRef = inject(TemplateRef); + viewContainer = inject(ViewContainerRef); + authService = inject(AuthService); + + @Input() set dksRole(roles: string[]) { + this.allowedRoles$.next(roles); + } + + constructor() { + this.subscription.add( + combineLatest([ + this.allowedRoles$.pipe( + distinctUntilChanged((prev, current) => + prev.some((item, index) => item !== current[index]) + ) + ), + this.authService.roles$, + ]) + .pipe( + tap(([allowedRoles, userRoles]) => { + const hasUserAllowedRole = allowedRoles.some((role) => + userRoles?.includes(role) + ); + if (hasUserAllowedRole && !this.hasView) { + this.viewContainer.createEmbeddedView(this.templateRef); + this.hasView = true; + } else if (!hasUserAllowedRole && this.hasView) { + this.viewContainer.clear(); + this.hasView = false; + } + }) + ) + .subscribe() + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } +} diff --git a/service/app-hub/dashboard/src/app/core/components/ms-authentication/role.guard.ts b/service/app-hub/dashboard/src/app/core/components/ms-authentication/role.guard.ts new file mode 100644 index 0000000..5ff4fc9 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/ms-authentication/role.guard.ts @@ -0,0 +1,15 @@ +import { inject } from '@angular/core'; +import { CanActivateFn } from '@angular/router'; +import { map } from 'rxjs'; +import { AuthService } from './auth.service'; + +export const roleGuard: CanActivateFn = (route) => { + if (!route.data?.['roles']) { + return true; + } + const routeRoles: string[] = route.data['roles']; + const authService = inject(AuthService); + return authService.roles$.pipe( + map((userRoles) => routeRoles.some((role) => userRoles?.includes(role))) + ); +}; diff --git a/service/app-hub/dashboard/src/app/core/components/ngx-echarts/echarts.directive.ts b/service/app-hub/dashboard/src/app/core/components/ngx-echarts/echarts.directive.ts new file mode 100644 index 0000000..357dee3 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/ngx-echarts/echarts.directive.ts @@ -0,0 +1,109 @@ +import { + Directive, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + SimpleChanges, +} from '@angular/core'; +import { EChartsOption } from 'echarts'; +import { filter, interval, map, Observable, Subscription } from 'rxjs'; +import * as echarts from 'echarts/core'; +import { CanvasRenderer } from 'echarts/renderers'; + +@Directive({ + selector: '[dksEcharts]' +}) +export class EchartsDirective implements OnInit, OnDestroy, OnChanges { + @Input() options?: EChartsOption; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + @Input() extentions: any[] = []; + @Input() isResizable = true; + @Input() defaultWidth = 400; + @Input() defaultHeight = 400; + @Input() periodicityInMilliseconds = 2000; + @Input() theme: Record | string = ''; + + @Output() chartInit = new EventEmitter(); + + protected echartsInstance: echarts.ECharts | undefined; + + protected subscription: Subscription | undefined; + + constructor(protected readonly _el: ElementRef) {} + + ngOnInit(): void { + echarts.use([...this.extentions, CanvasRenderer]); + this.echartsInstance = echarts.init(this._el.nativeElement, this.theme, { + width: + this._el.nativeElement.clientWidth === 0 + ? this.defaultWidth + : undefined, + height: + this._el.nativeElement.clientHeight === 0 + ? this.defaultHeight + : undefined, + }); + this.chartInit.emit(this.echartsInstance); + this.setParams(); + if (this.isResizable) { + this.addResizableFunctionality(); + } + } + + ngOnDestroy(): void { + if (this.subscription != null) this.subscription.unsubscribe(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['options'] && !changes['options'].firstChange) { + this.setParams(); + } + + if (changes['isResizable'] && !changes['isResizable'].firstChange) { + if (this.isResizable) { + this.addResizableFunctionality(); + } else { + if (this.subscription != null) this.subscription.unsubscribe(); + } + } + } + + protected addResizableFunctionality() { + if (this.subscription != null) this.subscription.unsubscribe(); + this.subscription = this.getWidthSensor( + this._el.nativeElement, + this.periodicityInMilliseconds + ).subscribe(() => { + if (this.echartsInstance != null) { + this.echartsInstance.resize(); + } + }); + } + + protected setParams() { + if (this.echartsInstance != null && this.options != null) { + this.echartsInstance.setOption(this.options, true); + } + } + + private getWidthSensor( + element: HTMLElement, + periodicityInMilliseconds: number = 2000 + ): Observable { + let oldWidth = element.clientWidth; + return interval(periodicityInMilliseconds).pipe( + filter(() => { + if (Math.abs(oldWidth - element.clientWidth) > 2) { + oldWidth = element.clientWidth; + return true; + } + return false; + }), + map(() => element.clientWidth) + ); + } +} diff --git a/service/app-hub/dashboard/src/app/core/components/ngx-echarts/util-ngx-echarts.module.ts b/service/app-hub/dashboard/src/app/core/components/ngx-echarts/util-ngx-echarts.module.ts new file mode 100644 index 0000000..e65d970 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/components/ngx-echarts/util-ngx-echarts.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; + +import { EchartsDirective } from './echarts.directive'; + +@NgModule({ + declarations: [EchartsDirective], + exports: [EchartsDirective], +}) +export class UtilNgxEchartsModule {} diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/avicenna-data-access-graphql.module.ts b/service/app-hub/dashboard/src/app/core/data-access/graphql/avicenna-data-access-graphql.module.ts new file mode 100644 index 0000000..98960d1 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/avicenna-data-access-graphql.module.ts @@ -0,0 +1,7 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +@NgModule({ + imports: [CommonModule], +}) +export class AvicennaDataAccessGraphqlModule {} diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/generated/generated.ts b/service/app-hub/dashboard/src/app/core/data-access/graphql/generated/generated.ts new file mode 100644 index 0000000..51f62ed --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/generated/generated.ts @@ -0,0 +1,1312 @@ +import { gql } from 'apollo-angular'; +import { Injectable } from '@angular/core'; +import * as Apollo from 'apollo-angular'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + /** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */ + DateTime: { input: any; output: any; } +}; + +export type AccountingFilterArgs = { + deviation?: InputMaybe>; + missing?: InputMaybe>; +}; + +export enum AccountingState { + Billable = 'Billable', + Billed = 'Billed', + PreparedForBilling = 'PreparedForBilling' +} + +export type Anomaly = { + __typename?: 'Anomaly'; + createdAt: Scalars['DateTime']['output']; + description: Scalars['String']['output']; + groupDescription: Scalars['String']['output']; + groupKey: Scalars['String']['output']; + id: Scalars['String']['output']; + manuallyResolved?: Maybe; + resolvedAt?: Maybe; + sleepSince?: Maybe; + sleepTimer: Scalars['Int']['output']; + solution: Array; + tour?: Maybe; + type: Scalars['String']['output']; + workedOnBy: Scalars['String']['output']; +}; + +export type AnomalyGroup = { + __typename?: 'AnomalyGroup'; + anomalies: Array; + key: Scalars['String']['output']; +}; + +export type ApprovalInfo = { + __typename?: 'ApprovalInfo'; + answeredFrom?: Maybe; + isNeeded?: Maybe; + location?: Maybe; + whoRequested?: Maybe; +}; + +export type ApprovalInfoArgs = { + answeredFrom?: InputMaybe; + isNeeded?: InputMaybe; + location?: InputMaybe; + whoRequested?: InputMaybe; +}; + +export enum ApprovalState { + Approved = 'APPROVED', + Free = 'FREE', + Requested = 'REQUESTED', + Required = 'REQUIRED', + Unknown = 'UNKNOWN' +} + +export type DocumentInfo = { + __typename?: 'DocumentInfo'; + approval: ApprovalInfo; + tdLocation?: Maybe; +}; + +export type Interval = { + __typename?: 'Interval'; + end: Scalars['DateTime']['output']; + start: Scalars['DateTime']['output']; +}; + +export type Kpi = { + __typename?: 'KPI'; + meta: KpiMetadata; + value: Scalars['Float']['output']; +}; + +export type KpiByCar = { + __typename?: 'KPIByCar'; + carName: Scalars['String']['output']; + kpi: Kpi; +}; + +export type KpiMetadata = { + __typename?: 'KPIMetadata'; + availableTime: Scalars['Float']['output']; + baseValue: Scalars['Float']['output']; + breakEven: Scalars['Float']['output']; + tourAmount: Scalars['Float']['output']; +}; + +export type Mutation = { + __typename?: 'Mutation'; + acceptAnomaly: Anomaly; + setAnomalyToSleep: Anomaly; + setDocumentInfo: Ticket; +}; + + +export type MutationAcceptAnomalyArgs = { + id: Scalars['String']['input']; +}; + + +export type MutationSetAnomalyToSleepArgs = { + id: Scalars['String']['input']; +}; + + +export type MutationSetDocumentInfoArgs = { + approval?: InputMaybe; + tdLocation?: InputMaybe; + ticketId: Scalars['String']['input']; +}; + +export type PaginatedAnomaly = { + __typename?: 'PaginatedAnomaly'; + endCursor: Scalars['String']['output']; + hasNextPage: Scalars['Boolean']['output']; + nodes: Array; + prevCursor: Scalars['String']['output']; + totalCount: Scalars['Int']['output']; +}; + +export type PaginatedAnomalyGroup = { + __typename?: 'PaginatedAnomalyGroup'; + endCursor: Scalars['String']['output']; + hasNextPage: Scalars['Boolean']['output']; + nodes: Array; + prevCursor: Scalars['String']['output']; + totalCount: Scalars['Int']['output']; +}; + +export type PaginatedTicket = { + __typename?: 'PaginatedTicket'; + endCursor: Scalars['String']['output']; + hasNextPage: Scalars['Boolean']['output']; + nodes: Array; + prevCursor: Scalars['String']['output']; + totalCount: Scalars['Int']['output']; +}; + +export type PaginatedTour = { + __typename?: 'PaginatedTour'; + endCursor: Scalars['String']['output']; + hasNextPage: Scalars['Boolean']['output']; + nodes: Array; + prevCursor: Scalars['String']['output']; + totalCount: Scalars['Int']['output']; +}; + +export type Query = { + __typename?: 'Query'; + accountingStateMeta: TourStateMeta; + accountingTours: PaginatedTour; + anomalies: PaginatedAnomaly; + anomaliesProto: Array; + anomaly: Anomaly; + anomalyAmountByType: Array; + averageAnomalyResolutionTimeReport: Array; + groupedAnomalies: PaginatedAnomalyGroup; + searchTickets: Array; + ticket: Ticket; + ticketStateKeyFigures: TicketStateKeyFigures; + ticketStateMeta: TicketStateMeta; + tickets: PaginatedTicket; + timePerTourMetricForDay: Array; + timePerTourMetricForMonth: Array; + tour: Tour; + toursPerTimeKPI: Array; + upcomingTourNotificationReport: Array; + upcomingTours: Array; +}; + + +export type QueryAccountingStateMetaArgs = { + state: AccountingState; +}; + + +export type QueryAccountingToursArgs = { + cursor?: InputMaybe; + filters?: InputMaybe; + state: AccountingState; + take?: Scalars['Int']['input']; +}; + + +export type QueryAnomaliesArgs = { + cursor?: InputMaybe; + take?: Scalars['Int']['input']; +}; + + +export type QueryAnomalyArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryGroupedAnomaliesArgs = { + cursor?: InputMaybe; + take?: Scalars['Int']['input']; +}; + + +export type QuerySearchTicketsArgs = { + query: Scalars['String']['input']; +}; + + +export type QueryTicketArgs = { + id: Scalars['String']['input']; +}; + + +export type QueryTicketStateKeyFiguresArgs = { + state: TicketValidationState; +}; + + +export type QueryTicketStateMetaArgs = { + state: TicketValidationState; +}; + + +export type QueryTicketsArgs = { + cursor?: InputMaybe; + filters?: InputMaybe; + state: TicketValidationState; + take?: Scalars['Int']['input']; +}; + + +export type QueryTimePerTourMetricForDayArgs = { + day: Scalars['DateTime']['input']; + ordinanceType: Scalars['String']['input']; +}; + + +export type QueryTimePerTourMetricForMonthArgs = { + day: Scalars['DateTime']['input']; + ordinanceType: Scalars['String']['input']; +}; + + +export type QueryTourArgs = { + operationId: Scalars['String']['input']; +}; + + +export type QueryToursPerTimeKpiArgs = { + ordinanceTypes: Scalars['String']['input']; +}; + +export type Report = { + __typename?: 'Report'; + label: Scalars['String']['output']; + value: Scalars['Float']['output']; +}; + +export type SolutionTarget = { + __typename?: 'SolutionTarget'; + anomalyId: Scalars['String']['output']; + id: Scalars['String']['output']; + name: Scalars['String']['output']; + steps: Array; +}; + +export type SolutionTargetStep = { + __typename?: 'SolutionTargetStep'; + description: Scalars['String']['output']; + id: Scalars['String']['output']; + solutionTargetId: Scalars['String']['output']; +}; + +export type Subscription = { + __typename?: 'Subscription'; + ticketsUpdated: TicketsUpdated; + timePerTourUpdated: TimePerTourUpdate; +}; + +export type Ticket = { + __typename?: 'Ticket'; + approvalState?: Maybe; + createdAt: Scalars['DateTime']['output']; + currentState: TicketValidationState; + documentInfo?: Maybe; + errors: Array; + id: Scalars['String']['output']; + isUrgent: Scalars['Boolean']['output']; + notes: Array; + tours: Array; + urgency: Scalars['DateTime']['output']; +}; + +export type TicketFilterArgs = { + approvalStates?: InputMaybe>; + documentInfo?: InputMaybe>; + errors?: InputMaybe>; +}; + +export type TicketStateKeyFigures = { + __typename?: 'TicketStateKeyFigures'; + dwellTime: Scalars['Int']['output']; +}; + +export type TicketStateMeta = { + __typename?: 'TicketStateMeta'; + approvalNotCompanyCount?: Maybe; + approvalRequiredCount?: Maybe; + approvalUnknownCount?: Maybe; + count: Scalars['Int']['output']; + tdNotCompanyCount?: Maybe; +}; + +export enum TicketValidationState { + Archived = 'ARCHIVED', + Created = 'CREATED', + DocumentsMissing = 'DOCUMENTS_MISSING', + TdUncertain = 'TD_UNCERTAIN', + UserInteractionRequired = 'USER_INTERACTION_REQUIRED' +} + +export type TicketsUpdated = { + __typename?: 'TicketsUpdated'; + count: Scalars['Int']['output']; +}; + +export type TimePerTourKpiSummary = { + __typename?: 'TimePerTourKPISummary'; + cars: Array; + interval: Interval; + kpi: Kpi; +}; + +export type TimePerTourMetric = { + __typename?: 'TimePerTourMetric'; + availableTime: Scalars['Float']['output']; + averageRevenue: Scalars['Float']['output']; + breakEven: Scalars['Float']['output']; + categoryAmounts: Array; + consumptionCost: Scalars['Float']['output']; + forecast: Scalars['Float']['output']; + forecastRevenue: Scalars['Float']['output']; + interval: Interval; + personnelCost: Scalars['Float']['output']; + revenue: Scalars['Float']['output']; + tourAmount: Scalars['Float']['output']; +}; + +export type TimePerTourMetricCategoryAmount = { + __typename?: 'TimePerTourMetricCategoryAmount'; + amount: Scalars['Int']['output']; + category: Scalars['String']['output']; +}; + +export type TimePerTourUpdate = { + __typename?: 'TimePerTourUpdate'; + job: Scalars['String']['output']; +}; + +export type Tour = { + __typename?: 'Tour'; + billDate?: Maybe; + billNumber?: Maybe; + carName?: Maybe; + category?: Maybe; + check: Scalars['Int']['output']; + codriverId?: Maybe; + codriverName?: Maybe; + consumptionCosts: Scalars['Float']['output']; + createdAt?: Maybe; + deletedAt?: Maybe; + direction: Scalars['String']['output']; + done: Scalars['Boolean']['output']; + driverId?: Maybe; + driverName?: Maybe; + empty?: Maybe; + hasInfection?: Maybe; + healthInsurance?: Maybe; + healthInsuranceNumber?: Maybe; + id: Scalars['String']['output']; + infectionName?: Maybe; + occupiedKm: Scalars['Float']['output']; + operationId: Scalars['String']['output']; + ordinanceType?: Maybe; + patientCity?: Maybe; + patientId?: Maybe; + patientName?: Maybe; + patientStreet?: Maybe; + patientSurname?: Maybe; + patientZip?: Maybe; + rangeEndDate?: Maybe; + revenue: Scalars['Float']['output']; + revenueDeviation: Scalars['Float']['output']; + revenueDispoLive: Scalars['Float']['output']; + startBegin?: Maybe; + startCity?: Maybe; + startDate?: Maybe; + startEnd?: Maybe; + startInstitution?: Maybe; + startStreet?: Maybe; + startZip?: Maybe; + target?: Maybe; + targetBegin?: Maybe; + targetCity?: Maybe; + targetInstitution?: Maybe; + targetStreet?: Maybe; + targetZip?: Maybe; + ticket?: Maybe; + ticketId?: Maybe; + totalKm: Scalars['Float']['output']; + transportType?: Maybe; + type: Scalars['String']['output']; +}; + +export type TourStateMeta = { + __typename?: 'TourStateMeta'; + count: Scalars['Int']['output']; + revenueSum?: Maybe; +}; + +export type AnomalyAllFragment = { __typename?: 'Anomaly', id: string, type: string, description: string, groupKey: string, groupDescription: string, sleepTimer: number, sleepSince?: any | null, workedOnBy: string, resolvedAt?: any | null, createdAt: any, solution: Array<{ __typename?: 'SolutionTarget', anomalyId: string, id: string, name: string, steps: Array<{ __typename?: 'SolutionTargetStep', description: string, id: string, solutionTargetId: string }> }> }; + +export type GetAnomaliesQueryVariables = Exact<{ + cursor?: InputMaybe; + take: Scalars['Int']['input']; +}>; + + +export type GetAnomaliesQuery = { __typename?: 'Query', anomalies: { __typename?: 'PaginatedAnomaly', prevCursor: string, endCursor: string, totalCount: number, hasNextPage: boolean, nodes: Array<{ __typename?: 'Anomaly', id: string, type: string, description: string, groupKey: string, groupDescription: string, sleepTimer: number, sleepSince?: any | null, workedOnBy: string, resolvedAt?: any | null, createdAt: any, solution: Array<{ __typename?: 'SolutionTarget', anomalyId: string, id: string, name: string, steps: Array<{ __typename?: 'SolutionTargetStep', description: string, id: string, solutionTargetId: string }> }> }> } }; + +export type GetGroupedAnomaliesQueryVariables = Exact<{ + cursor?: InputMaybe; + take: Scalars['Int']['input']; +}>; + + +export type GetGroupedAnomaliesQuery = { __typename?: 'Query', groupedAnomalies: { __typename?: 'PaginatedAnomalyGroup', prevCursor: string, endCursor: string, totalCount: number, hasNextPage: boolean, nodes: Array<{ __typename?: 'AnomalyGroup', key: string, anomalies: Array<{ __typename?: 'Anomaly', id: string, type: string, description: string, groupKey: string, groupDescription: string, sleepTimer: number, sleepSince?: any | null, workedOnBy: string, resolvedAt?: any | null, createdAt: any, solution: Array<{ __typename?: 'SolutionTarget', anomalyId: string, id: string, name: string, steps: Array<{ __typename?: 'SolutionTargetStep', description: string, id: string, solutionTargetId: string }> }> }> }> } }; + +export type SetAnomalyToSleepMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type SetAnomalyToSleepMutation = { __typename?: 'Mutation', setAnomalyToSleep: { __typename?: 'Anomaly', id: string, type: string, description: string, groupKey: string, groupDescription: string, sleepTimer: number, sleepSince?: any | null, workedOnBy: string, resolvedAt?: any | null, createdAt: any, solution: Array<{ __typename?: 'SolutionTarget', anomalyId: string, id: string, name: string, steps: Array<{ __typename?: 'SolutionTargetStep', description: string, id: string, solutionTargetId: string }> }> } }; + +export type AcceptAnomalyMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + + +export type AcceptAnomalyMutation = { __typename?: 'Mutation', acceptAnomaly: { __typename?: 'Anomaly', id: string, type: string, description: string, groupKey: string, groupDescription: string, sleepTimer: number, sleepSince?: any | null, workedOnBy: string, resolvedAt?: any | null, createdAt: any, solution: Array<{ __typename?: 'SolutionTarget', anomalyId: string, id: string, name: string, steps: Array<{ __typename?: 'SolutionTargetStep', description: string, id: string, solutionTargetId: string }> }> } }; + +export type TimePerTourUpdatedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type TimePerTourUpdatedSubscription = { __typename?: 'Subscription', timePerTourUpdated: { __typename?: 'TimePerTourUpdate', job: string } }; + +export type GetTourAmountPerTimeKpiQueryVariables = Exact<{ + ordinanceType: Scalars['String']['input']; +}>; + + +export type GetTourAmountPerTimeKpiQuery = { __typename?: 'Query', toursPerTimeKPI: Array<{ __typename?: 'TimePerTourKPISummary', kpi: { __typename?: 'KPI', value: number }, interval: { __typename?: 'Interval', start: any, end: any } }> }; + +export type GetTourAmountPerTimeKpiManagerQueryVariables = Exact<{ + ordinanceType: Scalars['String']['input']; +}>; + + +export type GetTourAmountPerTimeKpiManagerQuery = { __typename?: 'Query', toursPerTimeKPI: Array<{ __typename?: 'TimePerTourKPISummary', kpi: { __typename?: 'KPI', value: number, meta: { __typename?: 'KPIMetadata', availableTime: number, baseValue: number, tourAmount: number, breakEven: number } }, interval: { __typename?: 'Interval', start: any, end: any }, cars: Array<{ __typename?: 'KPIByCar', carName: string, kpi: { __typename?: 'KPI', value: number, meta: { __typename?: 'KPIMetadata', availableTime: number, baseValue: number, tourAmount: number, breakEven: number } } }> }> }; + +export type GetTimePerTourMetricForDayQueryVariables = Exact<{ + day: Scalars['DateTime']['input']; + ordinanceType: Scalars['String']['input']; +}>; + + +export type GetTimePerTourMetricForDayQuery = { __typename?: 'Query', timePerTourMetricForDay: Array<{ __typename?: 'TimePerTourMetric', availableTime: number, tourAmount: number, breakEven: number, revenue: number, averageRevenue: number, personnelCost: number, consumptionCost: number, forecast: number, forecastRevenue: number, interval: { __typename?: 'Interval', start: any, end: any }, categoryAmounts: Array<{ __typename?: 'TimePerTourMetricCategoryAmount', category: string, amount: number }> }> }; + +export type GetTimePerTourMetricForMonthQueryVariables = Exact<{ + day: Scalars['DateTime']['input']; + ordinanceType: Scalars['String']['input']; +}>; + + +export type GetTimePerTourMetricForMonthQuery = { __typename?: 'Query', timePerTourMetricForMonth: Array<{ __typename?: 'TimePerTourMetric', availableTime: number, tourAmount: number, breakEven: number, revenue: number, averageRevenue: number, personnelCost: number, consumptionCost: number, forecast: number, forecastRevenue: number, interval: { __typename?: 'Interval', start: any, end: any }, categoryAmounts: Array<{ __typename?: 'TimePerTourMetricCategoryAmount', category: string, amount: number }> }> }; + +export type GetAnomalyByTypeReportQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetAnomalyByTypeReportQuery = { __typename?: 'Query', anomalyAmountByType: Array<{ __typename?: 'Report', label: string, value: number }> }; + +export type GetUpcomingTourNotificationReportQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetUpcomingTourNotificationReportQuery = { __typename?: 'Query', upcomingTourNotificationReport: Array<{ __typename?: 'Report', label: string, value: number }> }; + +export type GetAverageAnomalyResolutionTimeReportQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetAverageAnomalyResolutionTimeReportQuery = { __typename?: 'Query', averageAnomalyResolutionTimeReport: Array<{ __typename?: 'Report', label: string, value: number }> }; + +export type TicketsUpdatedSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type TicketsUpdatedSubscription = { __typename?: 'Subscription', ticketsUpdated: { __typename?: 'TicketsUpdated', count: number } }; + +export type GetTicketStateMetaQueryVariables = Exact<{ + state: TicketValidationState; +}>; + + +export type GetTicketStateMetaQuery = { __typename?: 'Query', ticketStateMeta: { __typename?: 'TicketStateMeta', count: number, approvalUnknownCount?: number | null, approvalRequiredCount?: number | null, tdNotCompanyCount?: number | null, approvalNotCompanyCount?: number | null } }; + +export type SearchTicketsQueryVariables = Exact<{ + query: Scalars['String']['input']; +}>; + + +export type SearchTicketsQuery = { __typename?: 'Query', searchTickets: Array<{ __typename?: 'Ticket', id: string, currentState: TicketValidationState, approvalState?: ApprovalState | null, errors: Array, notes: Array, isUrgent: boolean, urgency: any, createdAt: any, documentInfo?: { __typename?: 'DocumentInfo', tdLocation?: string | null, approval: { __typename?: 'ApprovalInfo', isNeeded?: boolean | null, whoRequested?: string | null, answeredFrom?: string | null, location?: string | null } } | null, tours: Array<{ __typename?: 'Tour', id: string, done: boolean, direction: string, carName?: string | null, operationId: string, driverName?: string | null, codriverName?: string | null, patientName?: string | null, patientSurname?: string | null, patientStreet?: string | null, patientZip?: string | null, patientCity?: string | null, healthInsurance?: string | null, category?: string | null, transportType?: string | null, ordinanceType?: string | null, startStreet?: string | null, startZip?: string | null, startCity?: string | null, targetStreet?: string | null, targetZip?: string | null, targetCity?: string | null, startDate?: any | null, check: number, occupiedKm: number, totalKm: number, type: string, rangeEndDate?: any | null, startBegin?: string | null, startEnd?: string | null, target?: string | null, targetBegin?: string | null, empty?: string | null, billDate?: any | null, billNumber?: string | null, consumptionCosts: number, revenue: number, revenueDispoLive: number, startInstitution?: string | null, targetInstitution?: string | null, ticketId?: string | null }> }> }; + +export type GetTicketsQueryVariables = Exact<{ + state: TicketValidationState; + filters?: InputMaybe; + cursor?: InputMaybe; + take?: InputMaybe; +}>; + + +export type GetTicketsQuery = { __typename?: 'Query', tickets: { __typename?: 'PaginatedTicket', prevCursor: string, endCursor: string, hasNextPage: boolean, totalCount: number, nodes: Array<{ __typename?: 'Ticket', id: string, currentState: TicketValidationState, approvalState?: ApprovalState | null, errors: Array, notes: Array, isUrgent: boolean, urgency: any, createdAt: any, documentInfo?: { __typename?: 'DocumentInfo', tdLocation?: string | null, approval: { __typename?: 'ApprovalInfo', isNeeded?: boolean | null, whoRequested?: string | null, answeredFrom?: string | null, location?: string | null } } | null, tours: Array<{ __typename?: 'Tour', id: string, done: boolean, direction: string, carName?: string | null, operationId: string, driverName?: string | null, codriverName?: string | null, patientName?: string | null, patientSurname?: string | null, patientStreet?: string | null, patientZip?: string | null, patientCity?: string | null, healthInsurance?: string | null, category?: string | null, transportType?: string | null, ordinanceType?: string | null, startStreet?: string | null, startZip?: string | null, startCity?: string | null, targetStreet?: string | null, targetZip?: string | null, targetCity?: string | null, startDate?: any | null, check: number, occupiedKm: number, totalKm: number, type: string, rangeEndDate?: any | null, startBegin?: string | null, startEnd?: string | null, target?: string | null, targetBegin?: string | null, empty?: string | null, billDate?: any | null, billNumber?: string | null, consumptionCosts: number, revenue: number, revenueDispoLive: number, startInstitution?: string | null, targetInstitution?: string | null, ticketId?: string | null, ticket?: { __typename?: 'Ticket', currentState: TicketValidationState } | null }> }> } }; + +export type SetDocumentInfoMutationVariables = Exact<{ + ticketId: Scalars['String']['input']; + approval?: InputMaybe; + tdLocation?: InputMaybe; +}>; + + +export type SetDocumentInfoMutation = { __typename?: 'Mutation', setDocumentInfo: { __typename?: 'Ticket', id: string, currentState: TicketValidationState, approvalState?: ApprovalState | null, errors: Array, notes: Array, isUrgent: boolean, urgency: any, createdAt: any, documentInfo?: { __typename?: 'DocumentInfo', tdLocation?: string | null, approval: { __typename?: 'ApprovalInfo', isNeeded?: boolean | null, whoRequested?: string | null, answeredFrom?: string | null, location?: string | null } } | null, tours: Array<{ __typename?: 'Tour', id: string, done: boolean, direction: string, carName?: string | null, operationId: string, driverName?: string | null, codriverName?: string | null, patientName?: string | null, patientSurname?: string | null, patientStreet?: string | null, patientZip?: string | null, patientCity?: string | null, healthInsurance?: string | null, category?: string | null, transportType?: string | null, ordinanceType?: string | null, startStreet?: string | null, startZip?: string | null, startCity?: string | null, targetStreet?: string | null, targetZip?: string | null, targetCity?: string | null, startDate?: any | null, check: number, occupiedKm: number, totalKm: number, type: string, rangeEndDate?: any | null, startBegin?: string | null, startEnd?: string | null, target?: string | null, targetBegin?: string | null, empty?: string | null }> } }; + +export type GetUpcomingToursQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetUpcomingToursQuery = { __typename?: 'Query', upcomingTours: Array<{ __typename?: 'Tour', id: string, operationId: string, carName?: string | null, driverName?: string | null, codriverName?: string | null, startDate?: any | null, patientName?: string | null, patientSurname?: string | null, patientStreet?: string | null, patientZip?: string | null, patientCity?: string | null, startInstitution?: string | null, startStreet?: string | null, startZip?: string | null, startCity?: string | null, targetInstitution?: string | null, targetStreet?: string | null, targetZip?: string | null, targetCity?: string | null, ordinanceType?: string | null, createdAt?: any | null }> }; + +export type GetAccountingStateMetaQueryVariables = Exact<{ + state: AccountingState; +}>; + + +export type GetAccountingStateMetaQuery = { __typename?: 'Query', accountingStateMeta: { __typename?: 'TourStateMeta', count: number, revenueSum?: number | null } }; + +export type GetAccountingToursQueryVariables = Exact<{ + state: AccountingState; + filters?: InputMaybe; + cursor?: InputMaybe; + take?: InputMaybe; +}>; + + +export type GetAccountingToursQuery = { __typename?: 'Query', accountingTours: { __typename?: 'PaginatedTour', prevCursor: string, endCursor: string, hasNextPage: boolean, totalCount: number, nodes: Array<{ __typename?: 'Tour', id: string, done: boolean, direction: string, carName?: string | null, operationId: string, driverName?: string | null, codriverName?: string | null, patientName?: string | null, patientSurname?: string | null, patientStreet?: string | null, patientZip?: string | null, patientCity?: string | null, healthInsurance?: string | null, category?: string | null, transportType?: string | null, ordinanceType?: string | null, startStreet?: string | null, startZip?: string | null, startCity?: string | null, targetStreet?: string | null, targetZip?: string | null, targetCity?: string | null, startDate?: any | null, check: number, occupiedKm: number, totalKm: number, type: string, rangeEndDate?: any | null, startBegin?: string | null, startEnd?: string | null, target?: string | null, targetBegin?: string | null, empty?: string | null, billDate?: any | null, billNumber?: string | null, consumptionCosts: number, revenue: number, revenueDispoLive: number, revenueDeviation: number, startInstitution?: string | null, targetInstitution?: string | null, ticketId?: string | null, createdAt?: any | null, ticket?: { __typename?: 'Ticket', id: string, currentState: TicketValidationState, approvalState?: ApprovalState | null, errors: Array, notes: Array, isUrgent: boolean, urgency: any, createdAt: any } | null }> } }; + +export const AnomalyAllFragmentDoc = gql` + fragment AnomalyAll on Anomaly { + id + type + description + groupKey + groupDescription + sleepTimer + sleepSince + solution { + anomalyId + id + name + steps { + description + id + solutionTargetId + } + } + workedOnBy + resolvedAt + createdAt +} + `; +export const GetAnomaliesDocument = gql` + query GetAnomalies($cursor: String, $take: Int!) { + anomalies(cursor: $cursor, take: $take) { + prevCursor + endCursor + totalCount + hasNextPage + nodes { + ...AnomalyAll + } + } +} + ${AnomalyAllFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class GetAnomaliesGQL extends Apollo.Query { + override document = GetAnomaliesDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetGroupedAnomaliesDocument = gql` + query GetGroupedAnomalies($cursor: String, $take: Int!) { + groupedAnomalies(cursor: $cursor, take: $take) { + prevCursor + endCursor + totalCount + hasNextPage + nodes { + key + anomalies { + ...AnomalyAll + } + } + } +} + ${AnomalyAllFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class GetGroupedAnomaliesGQL extends Apollo.Query { + override document = GetGroupedAnomaliesDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const SetAnomalyToSleepDocument = gql` + mutation SetAnomalyToSleep($id: String!) { + setAnomalyToSleep(id: $id) { + ...AnomalyAll + } +} + ${AnomalyAllFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class SetAnomalyToSleepGQL extends Apollo.Mutation { + override document = SetAnomalyToSleepDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const AcceptAnomalyDocument = gql` + mutation AcceptAnomaly($id: String!) { + acceptAnomaly(id: $id) { + ...AnomalyAll + } +} + ${AnomalyAllFragmentDoc}`; + + @Injectable({ + providedIn: 'root' + }) + export class AcceptAnomalyGQL extends Apollo.Mutation { + override document = AcceptAnomalyDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const TimePerTourUpdatedDocument = gql` + subscription TimePerTourUpdated { + timePerTourUpdated { + job + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class TimePerTourUpdatedGQL extends Apollo.Subscription { + override document = TimePerTourUpdatedDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetTourAmountPerTimeKpiDocument = gql` + query GetTourAmountPerTimeKPI($ordinanceType: String!) { + toursPerTimeKPI(ordinanceTypes: $ordinanceType) { + kpi { + value + } + interval { + start + end + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetTourAmountPerTimeKpiGQL extends Apollo.Query { + override document = GetTourAmountPerTimeKpiDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetTourAmountPerTimeKpiManagerDocument = gql` + query GetTourAmountPerTimeKPIManager($ordinanceType: String!) { + toursPerTimeKPI(ordinanceTypes: $ordinanceType) { + kpi { + value + meta { + availableTime + baseValue + tourAmount + breakEven + } + } + interval { + start + end + } + cars { + carName + kpi { + value + meta { + availableTime + baseValue + tourAmount + breakEven + } + } + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetTourAmountPerTimeKpiManagerGQL extends Apollo.Query { + override document = GetTourAmountPerTimeKpiManagerDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetTimePerTourMetricForDayDocument = gql` + query GetTimePerTourMetricForDay($day: DateTime!, $ordinanceType: String!) { + timePerTourMetricForDay(day: $day, ordinanceType: $ordinanceType) { + interval { + start + end + } + availableTime + tourAmount + breakEven + revenue + averageRevenue + personnelCost + consumptionCost + forecast + forecastRevenue + categoryAmounts { + category + amount + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetTimePerTourMetricForDayGQL extends Apollo.Query { + override document = GetTimePerTourMetricForDayDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetTimePerTourMetricForMonthDocument = gql` + query GetTimePerTourMetricForMonth($day: DateTime!, $ordinanceType: String!) { + timePerTourMetricForMonth(day: $day, ordinanceType: $ordinanceType) { + interval { + start + end + } + availableTime + tourAmount + breakEven + revenue + averageRevenue + personnelCost + consumptionCost + forecast + forecastRevenue + categoryAmounts { + category + amount + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetTimePerTourMetricForMonthGQL extends Apollo.Query { + override document = GetTimePerTourMetricForMonthDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetAnomalyByTypeReportDocument = gql` + query GetAnomalyByTypeReport { + anomalyAmountByType { + label + value + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetAnomalyByTypeReportGQL extends Apollo.Query { + override document = GetAnomalyByTypeReportDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetUpcomingTourNotificationReportDocument = gql` + query GetUpcomingTourNotificationReport { + upcomingTourNotificationReport { + label + value + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetUpcomingTourNotificationReportGQL extends Apollo.Query { + override document = GetUpcomingTourNotificationReportDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetAverageAnomalyResolutionTimeReportDocument = gql` + query GetAverageAnomalyResolutionTimeReport { + averageAnomalyResolutionTimeReport { + label + value + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetAverageAnomalyResolutionTimeReportGQL extends Apollo.Query { + override document = GetAverageAnomalyResolutionTimeReportDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const TicketsUpdatedDocument = gql` + subscription TicketsUpdated { + ticketsUpdated { + count + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class TicketsUpdatedGQL extends Apollo.Subscription { + override document = TicketsUpdatedDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetTicketStateMetaDocument = gql` + query GetTicketStateMeta($state: TicketValidationState!) { + ticketStateMeta(state: $state) { + count + approvalUnknownCount + approvalRequiredCount + tdNotCompanyCount + approvalNotCompanyCount + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetTicketStateMetaGQL extends Apollo.Query { + override document = GetTicketStateMetaDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const SearchTicketsDocument = gql` + query SearchTickets($query: String!) { + searchTickets(query: $query) { + id + currentState + approvalState + errors + notes + isUrgent + urgency + documentInfo { + tdLocation + approval { + isNeeded + whoRequested + answeredFrom + location + } + } + createdAt + tours { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + billDate + billNumber + consumptionCosts + revenue + revenueDispoLive + startInstitution + targetInstitution + ticketId + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class SearchTicketsGQL extends Apollo.Query { + override document = SearchTicketsDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetTicketsDocument = gql` + query GetTickets($state: TicketValidationState!, $filters: TicketFilterArgs, $cursor: String, $take: Int) { + tickets(state: $state, filters: $filters, cursor: $cursor, take: $take) { + prevCursor + endCursor + hasNextPage + totalCount + nodes { + id + currentState + approvalState + errors + notes + isUrgent + urgency + documentInfo { + tdLocation + approval { + isNeeded + whoRequested + answeredFrom + location + } + } + createdAt + tours { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + billDate + billNumber + consumptionCosts + revenue + revenueDispoLive + startInstitution + targetInstitution + ticketId + ticket { + currentState + } + } + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetTicketsGQL extends Apollo.Query { + override document = GetTicketsDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const SetDocumentInfoDocument = gql` + mutation SetDocumentInfo($ticketId: String!, $approval: ApprovalInfoArgs, $tdLocation: String) { + setDocumentInfo( + ticketId: $ticketId + approval: $approval + tdLocation: $tdLocation + ) { + id + currentState + approvalState + errors + notes + isUrgent + urgency + documentInfo { + tdLocation + approval { + isNeeded + whoRequested + answeredFrom + location + } + } + createdAt + tours { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class SetDocumentInfoGQL extends Apollo.Mutation { + override document = SetDocumentInfoDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetUpcomingToursDocument = gql` + query GetUpcomingTours { + upcomingTours { + id + operationId + carName + driverName + codriverName + startDate + patientName + patientSurname + patientStreet + patientZip + patientCity + startInstitution + startStreet + startZip + startCity + targetInstitution + targetStreet + targetZip + targetCity + ordinanceType + createdAt + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetUpcomingToursGQL extends Apollo.Query { + override document = GetUpcomingToursDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetAccountingStateMetaDocument = gql` + query GetAccountingStateMeta($state: AccountingState!) { + accountingStateMeta(state: $state) { + count + revenueSum + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetAccountingStateMetaGQL extends Apollo.Query { + override document = GetAccountingStateMetaDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } +export const GetAccountingToursDocument = gql` + query GetAccountingTours($state: AccountingState!, $filters: AccountingFilterArgs, $cursor: String, $take: Int) { + accountingTours(state: $state, filters: $filters, cursor: $cursor, take: $take) { + prevCursor + endCursor + hasNextPage + totalCount + nodes { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + billDate + billNumber + consumptionCosts + revenue + revenueDispoLive + revenueDeviation + startInstitution + targetInstitution + ticketId + createdAt + ticket { + id + currentState + approvalState + errors + notes + isUrgent + urgency + createdAt + } + } + } +} + `; + + @Injectable({ + providedIn: 'root' + }) + export class GetAccountingToursGQL extends Apollo.Query { + override document = GetAccountingToursDocument; + + constructor(apollo: Apollo.Apollo) { + super(apollo); + } + } \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/anomalies.graphql b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/anomalies.graphql new file mode 100644 index 0000000..c737250 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/anomalies.graphql @@ -0,0 +1,61 @@ +fragment AnomalyAll on Anomaly { + id + type + description + groupKey + groupDescription + sleepTimer + sleepSince + solution { + anomalyId + id + name + steps { + description + id + solutionTargetId + } + } + workedOnBy + resolvedAt + createdAt +} + +query GetAnomalies($cursor: String, $take: Int!) { + anomalies(cursor: $cursor, take: $take) { + prevCursor + endCursor + totalCount + hasNextPage + nodes { + ...AnomalyAll + } + } +} + +query GetGroupedAnomalies($cursor: String, $take: Int!) { + groupedAnomalies(cursor: $cursor, take: $take) { + prevCursor + endCursor + totalCount + hasNextPage + nodes { + key + anomalies { + ...AnomalyAll + } + } + } +} + +mutation SetAnomalyToSleep($id: String!) { + setAnomalyToSleep(id: $id) { + ...AnomalyAll + } +} + +mutation AcceptAnomaly($id: String!) { + acceptAnomaly(id: $id) { + ...AnomalyAll + } +} diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/kpis.graphql b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/kpis.graphql new file mode 100644 index 0000000..960b0cc --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/kpis.graphql @@ -0,0 +1,91 @@ +subscription TimePerTourUpdated { + timePerTourUpdated { + job + } +} + +query GetTourAmountPerTimeKPI($ordinanceType: String!) { + toursPerTimeKPI(ordinanceTypes: $ordinanceType) { + kpi { + value + } + interval { + start + end + } + } +} + +query GetTourAmountPerTimeKPIManager($ordinanceType: String!) { + toursPerTimeKPI(ordinanceTypes: $ordinanceType) { + kpi { + value + meta { + availableTime + baseValue + tourAmount + breakEven + } + } + interval { + start + end + } + cars { + carName + kpi { + value + meta { + availableTime + baseValue + tourAmount + breakEven + } + } + } + } +} + +query GetTimePerTourMetricForDay($day: DateTime!, $ordinanceType: String!) { + timePerTourMetricForDay(day: $day, ordinanceType: $ordinanceType) { + interval { + start + end + } + availableTime + tourAmount + breakEven + revenue + averageRevenue + personnelCost + consumptionCost + forecast + forecastRevenue + categoryAmounts { + category + amount + } + } +} + +query GetTimePerTourMetricForMonth($day: DateTime!, $ordinanceType: String!) { + timePerTourMetricForMonth(day: $day, ordinanceType: $ordinanceType) { + interval { + start + end + } + availableTime + tourAmount + breakEven + revenue + averageRevenue + personnelCost + consumptionCost + forecast + forecastRevenue + categoryAmounts { + category + amount + } + } +} diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/reports.graphql b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/reports.graphql new file mode 100644 index 0000000..0a5d58b --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/reports.graphql @@ -0,0 +1,20 @@ +query GetAnomalyByTypeReport { + anomalyAmountByType { + label + value + } +} + +query GetUpcomingTourNotificationReport { + upcomingTourNotificationReport { + label + value + } +} + +query GetAverageAnomalyResolutionTimeReport { + averageAnomalyResolutionTimeReport { + label + value + } +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/tickets.graphql b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/tickets.graphql new file mode 100644 index 0000000..a159329 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/tickets.graphql @@ -0,0 +1,219 @@ +subscription TicketsUpdated { + ticketsUpdated { + count + } +} + +query GetTicketStateMeta($state: TicketValidationState!) { + ticketStateMeta(state: $state) { + count + approvalUnknownCount + approvalRequiredCount + tdNotCompanyCount + approvalNotCompanyCount + } +} + +query SearchTickets($query: String!) { + searchTickets(query: $query) { + id + currentState + approvalState + errors + notes + isUrgent + urgency + documentInfo { + tdLocation + approval { + isNeeded + whoRequested + answeredFrom + location + } + } + createdAt + tours { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + billDate + billNumber + consumptionCosts + revenue + revenueDispoLive + startInstitution + targetInstitution + ticketId + } + } +} + +query GetTickets($state: TicketValidationState!, $filters: TicketFilterArgs, $cursor: String, $take: Int) { + tickets(state: $state, filters: $filters, cursor: $cursor, take: $take) { + prevCursor + endCursor + hasNextPage + totalCount + nodes { + id + currentState + approvalState + errors + notes + isUrgent + urgency + documentInfo { + tdLocation + approval { + isNeeded + whoRequested + answeredFrom + location + } + } + createdAt + tours { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + billDate + billNumber + consumptionCosts + revenue + revenueDispoLive + startInstitution + targetInstitution + ticketId + ticket { + currentState + } + } + } + } +} + +mutation SetDocumentInfo( + $ticketId: String! + $approval: ApprovalInfoArgs + $tdLocation: String +) { + setDocumentInfo( + ticketId: $ticketId + approval: $approval + tdLocation: $tdLocation + ) { + id + currentState + approvalState + errors + notes + isUrgent + urgency + documentInfo { + tdLocation + approval { + isNeeded + whoRequested + answeredFrom + location + } + } + createdAt + tours { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + } + } +} diff --git a/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/tours.graphql b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/tours.graphql new file mode 100644 index 0000000..9680d76 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/data-access/graphql/graphql/tours.graphql @@ -0,0 +1,107 @@ +query GetUpcomingTours { + upcomingTours { + id + operationId + carName + driverName + codriverName + startDate + patientName + patientSurname + patientStreet + patientZip + patientCity + startInstitution + startStreet + startZip + startCity + targetInstitution + targetStreet + targetZip + targetCity + ordinanceType + createdAt + } +} + +query GetAccountingStateMeta($state: AccountingState!) { + accountingStateMeta(state: $state) { + count + revenueSum + } +} + +query GetAccountingTours( + $state: AccountingState! + $filters: AccountingFilterArgs + $cursor: String + $take: Int +) { + accountingTours( + state: $state + filters: $filters + cursor: $cursor + take: $take + ) { + prevCursor + endCursor + hasNextPage + totalCount + nodes { + id + done + direction + carName + operationId + driverName + codriverName + patientName + patientSurname + patientStreet + patientZip + patientCity + healthInsurance + category + transportType + ordinanceType + startStreet + startZip + startCity + targetStreet + targetZip + targetCity + startDate + check + occupiedKm + totalKm + type + rangeEndDate + startBegin + startEnd + target + targetBegin + empty + billDate + billNumber + consumptionCosts + revenue + revenueDispoLive + revenueDeviation + startInstitution + targetInstitution + ticketId + createdAt + + ticket { + id + currentState + approvalState + errors + notes + isUrgent + urgency + createdAt + } + } + } +} diff --git a/service/app-hub/dashboard/src/app/core/telemetry/external/faro-meta-attributes-span-processor.ts b/service/app-hub/dashboard/src/app/core/telemetry/external/faro-meta-attributes-span-processor.ts new file mode 100644 index 0000000..ba515a5 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/telemetry/external/faro-meta-attributes-span-processor.ts @@ -0,0 +1,68 @@ +// https://github.com/grafana/faro-web-sdk/tree/main +// https://github.com/grafana/faro-web-sdk/blob/main/packages/web-tracing/src/faroMetaAttributesSpanProcessor.ts +import type { Context } from '@opentelemetry/api'; +import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web'; +// False positive. Package can be resolved. +// eslint-disable-next-line import/no-unresolved +import { ATTR_SESSION_ID } from '@opentelemetry/semantic-conventions/incubating'; + +import type { Metas } from '@grafana/faro-web-sdk'; + +export class FaroMetaAttributesSpanProcessor implements SpanProcessor { + constructor( + private processor: SpanProcessor, + private metas: Metas + ) {} + + forceFlush(): Promise { + return this.processor.forceFlush(); + } + + onStart(span: Span, parentContext: Context): void { + const session = this.metas.value.session; + + if (session?.id) { + span.attributes[ATTR_SESSION_ID] = session.id; + /** + * @deprecated will be removed in the future and has been replaced by ATTR_SESSION_ID (session.id) + */ + span.attributes['session_id'] = session.id; + } + + const user = this.metas.value.user ?? {}; + + if (user.email) { + span.attributes['user.email'] = user.email; + } + + if (user.id) { + span.attributes['user.id'] = user.id; + } + + if (user.username) { + span.attributes['user.name'] = user.username; + } + + if (user.fullName) { + span.attributes['user.full_name'] = user.fullName; + } + + if (user.roles) { + span.attributes['user.roles'] = user.roles.split(',').map((role) => role.trim()); + } + + if (user.hash) { + span.attributes['user.hash'] = user.hash; + } + + this.processor.onStart(span, parentContext); + } + + onEnd(span: ReadableSpan): void { + this.processor.onEnd(span); + } + + shutdown(): Promise { + return this.processor.shutdown(); + } +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/core/telemetry/external/faro-user-action-span-processor.ts b/service/app-hub/dashboard/src/app/core/telemetry/external/faro-user-action-span-processor.ts new file mode 100644 index 0000000..7612a91 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/telemetry/external/faro-user-action-span-processor.ts @@ -0,0 +1,50 @@ +// https://github.com/grafana/faro-web-sdk/tree/main +// https://github.com/grafana/faro-web-sdk/blob/main/packages/web-tracing/src/faroUserActionSpanProcessor.ts +import { type Context, SpanKind } from '@opentelemetry/api'; +import type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-web'; + +import { USER_ACTION_CANCEL, USER_ACTION_END, USER_ACTION_START } from '@grafana/faro-core'; +import { apiMessageBus, type UserActionStartMessage } from '@grafana/faro-web-sdk'; + +export class FaroUserActionSpanProcessor implements SpanProcessor { + message: UserActionStartMessage | undefined; + + constructor(private processor: SpanProcessor) { + apiMessageBus.subscribe((msg) => { + if (msg.type === USER_ACTION_START) { + this.message = msg; + return; + } + + if ([USER_ACTION_END, USER_ACTION_CANCEL].includes(msg.type)) { + this.message = undefined; + } + }); + } + + forceFlush(): Promise { + return this.processor.forceFlush(); + } + + onStart(span: Span, parentContext: Context): void { + if (span.kind === SpanKind.CLIENT) { + // If the span is created when the message object is available it is created before the user action timeout has been reached so it belongs to the user-action. + // In this case we can add the user action name and parentId to the span attributes. + // If the span is created after the user action timeout span, the message object will be undefined which means the action has been cancelled or is ended. + if (this.message) { + span.attributes['faro.action.user.name'] = this.message?.name; + span.attributes['faro.action.user.parentId'] = this.message?.parentId; + } + } + + this.processor.onStart(span, parentContext); + } + + onEnd(span: ReadableSpan): void { + this.processor.onEnd(span); + } + + shutdown(): Promise { + return this.processor.shutdown(); + } +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/core/telemetry/faro-initializer.ts b/service/app-hub/dashboard/src/app/core/telemetry/faro-initializer.ts new file mode 100644 index 0000000..5505ae4 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/telemetry/faro-initializer.ts @@ -0,0 +1,122 @@ +import { + ConsoleTransport, + FetchTransport, + getWebInstrumentations, + initializeFaro, +} from '@grafana/faro-web-sdk'; +import { + FaroTraceExporter, + TracingInstrumentation, +} from '@grafana/faro-web-tracing'; +import { context, trace } from '@opentelemetry/api'; +import { getWebAutoInstrumentations } from '@opentelemetry/auto-instrumentations-web'; +import { ZoneContextManager } from '@opentelemetry/context-zone'; +import { W3CTraceContextPropagator } from '@opentelemetry/core'; +import { registerInstrumentations } from '@opentelemetry/instrumentation'; +import { Resource } from '@opentelemetry/resources'; +import { + BatchSpanProcessor, + ConsoleSpanExporter, + SimpleSpanProcessor, + WebTracerProvider, +} from '@opentelemetry/sdk-trace-web'; +import { + ATTR_SERVICE_NAME, + ATTR_SERVICE_VERSION, +} from '@opentelemetry/semantic-conventions'; +import { environment } from '../../../environments/environment'; +import { FaroMetaAttributesSpanProcessor } from './external/faro-meta-attributes-span-processor'; +import { FaroUserActionSpanProcessor } from './external/faro-user-action-span-processor'; + +const VERSION = '1.0.0'; + +export function faroInitializer(): Function { + return async () => { + const faro = initializeFaro({ + instrumentations: [ + ...getWebInstrumentations({ + captureConsole: true, + captureConsoleDisabledLevels: [], + }), + new TracingInstrumentation(), + ], + transports: [ + new FetchTransport({ + url: environment.telemetry.otlpEndpoint, + apiKey: environment.telemetry.collectorSecret, + }), + new ConsoleTransport(), + ], + trackResources: true, + trackUserActionsPreview: true, + app: { + name: environment.telemetry.serviceName, + version: VERSION, + namespace: 'avicenna', + }, + }); + + const resource = Resource.default().merge( + new Resource({ + [ATTR_SERVICE_NAME]: environment.telemetry.serviceName, + [ATTR_SERVICE_VERSION]: VERSION, + }) + ); + + const provider = new WebTracerProvider({ + resource, + spanProcessors: [ + new SimpleSpanProcessor(new ConsoleSpanExporter()), + new FaroUserActionSpanProcessor( + new FaroMetaAttributesSpanProcessor( + new BatchSpanProcessor(new FaroTraceExporter({ ...faro })), + { + add: () => {}, + remove: () => {}, + addListener: () => {}, + removeListener: () => {}, + value: { + session: { + id: 'session-id', + }, + user: { + email: 'email', + id: 'id', + username: 'user-short-name', + fullName: 'user-full-name', + roles: 'admin, editor,viewer', + hash: 'hash', + }, + }, + } + ) + ), + ], + }); + + provider.register({ + propagator: new W3CTraceContextPropagator(), + contextManager: new ZoneContextManager(), + }); + + const ignoreUrls = [environment.telemetry.otlpEndpoint]; + + registerInstrumentations({ + instrumentations: [ + getWebAutoInstrumentations({ + // not needed to add the following, but it better shows the intention + '@opentelemetry/instrumentation-document-load': {}, + '@opentelemetry/instrumentation-user-interaction': {}, + '@opentelemetry/instrumentation-fetch': { + ignoreUrls, + }, + '@opentelemetry/instrumentation-xml-http-request': { + ignoreUrls, + }, + }), + ], + }); + + faro.api.initOTEL(trace, context); + }; +} diff --git a/service/app-hub/dashboard/src/app/core/telemetry/global-error-handler.ts b/service/app-hub/dashboard/src/app/core/telemetry/global-error-handler.ts new file mode 100644 index 0000000..ca870df --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/telemetry/global-error-handler.ts @@ -0,0 +1,12 @@ +import { ErrorHandler, Injectable } from '@angular/core'; +import { faro } from '@grafana/faro-web-sdk'; + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + handleError(error: any) { + if (error instanceof Error) { + faro.api.pushError(error); + } + console.error(error); + } +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/core/util/group-by.spec.ts b/service/app-hub/dashboard/src/app/core/util/group-by.spec.ts new file mode 100644 index 0000000..ea3b5cb --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/util/group-by.spec.ts @@ -0,0 +1,14 @@ +import { groupBy } from './group-by'; + +describe('groupBy', () => { + it('should group a list by keys', () => { + const given = ['one', 'two', 'three']; + + const when = groupBy(given, (item) => item.length); + + expect(when).toEqual({ + 3: ['one', 'two'], + 5: ['three'], + }); + }); +}); diff --git a/service/app-hub/dashboard/src/app/core/util/group-by.ts b/service/app-hub/dashboard/src/app/core/util/group-by.ts new file mode 100644 index 0000000..5b8fd52 --- /dev/null +++ b/service/app-hub/dashboard/src/app/core/util/group-by.ts @@ -0,0 +1,12 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function groupBy( + list: T[], + getKey: (item: T) => K +) { + return list.reduce((previous, current) => { + const group = getKey(current); + if (!previous[group]) previous[group] = []; + previous[group].push(current); + return previous; + }, {} as Record); +} diff --git a/service/app-hub/dashboard/src/app/graphql.provider.ts b/service/app-hub/dashboard/src/app/graphql.provider.ts new file mode 100644 index 0000000..77a6c10 --- /dev/null +++ b/service/app-hub/dashboard/src/app/graphql.provider.ts @@ -0,0 +1,45 @@ +import { ApplicationConfig, inject } from '@angular/core'; +import { ApolloClientOptions, InMemoryCache, split } from '@apollo/client/core'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { getMainDefinition } from '@apollo/client/utilities'; +import { Apollo, APOLLO_OPTIONS } from 'apollo-angular'; +import { HttpLink } from 'apollo-angular/http'; +import { OperationDefinitionNode } from 'graphql'; +import { createClient } from 'graphql-ws'; +import { environment } from '../environments/environment'; + +const uri = environment.graphQl.endpoint; // <-- add the URL of the GraphQL server here +export function apolloOptionsFactory(): ApolloClientOptions { + const httpLink = inject(HttpLink); + + const http = httpLink.create({ uri }); + + const ws = new GraphQLWsLink( + createClient({ + url: environment.graphQl.ws, + }) + ); + + const link = split( + ({ query }) => { + const { kind, operation } = getMainDefinition( + query + ) as OperationDefinitionNode; + return kind === 'OperationDefinition' && operation === 'subscription'; + }, + ws, + http + ); + return { + link, + cache: new InMemoryCache(), + }; +} + +export const graphqlProvider: ApplicationConfig['providers'] = [ + Apollo, + { + provide: APOLLO_OPTIONS, + useFactory: apolloOptionsFactory, + }, +]; diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-item.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-item.component.ts new file mode 100644 index 0000000..4954c54 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-item.component.ts @@ -0,0 +1,93 @@ +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { CurrencyPipe, NgClass, NgIf } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + HostBinding, + Input, + inject, +} from '@angular/core'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { HotToastService } from '@ngxpert/hot-toast'; +import { Tour, Ticket, AccountingState, TicketValidationState } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-item', + template: `
+
+ + {{ tour.operationId }} +
+ {{ tour.ordinanceType }} +
+
+ + {{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }} + +
+ + {{ + tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' + }} + `, + styles: [ + ` + :host { + @apply flex flex-col rounded border p-2 m-1 bg-white cursor-pointer; + } + `, + ], + imports: [ + MatButtonModule, + MatIconModule, + MatBadgeModule, + ClipboardModule, + NgIf, + NgClass, + CurrencyPipe, + ], +}) +export class AccountingItemComponent implements AfterViewInit { + toast = inject(HotToastService); + + @Input() tour!: Tour & { ticket: Ticket }; + @Input() accountingState!: AccountingState; + + @HostBinding('style.border-color') + borderColor = '#ffffff'; + + AccountingState = AccountingState; + + ngAfterViewInit(): void { + if ( + this.tour.ticket.currentState !== TicketValidationState.Archived && + (this.accountingState === AccountingState.Billable || + this.accountingState === AccountingState.PreparedForBilling) + ) { + this.borderColor = '#dc2626'; + } + } + + notifyCopy(event: Event) { + event.stopPropagation(); + this.toast.success('Einsatznummer kopiert!', { + duration: 2000, + id: 'operationId-copy', + }); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-footer.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-footer.component.ts new file mode 100644 index 0000000..13ec120 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-footer.component.ts @@ -0,0 +1,31 @@ +import { AsyncPipe, CurrencyPipe, NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { TourStateMeta } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-lane-footer', + template: ` + {{ tourStateMeta.count }} Touren + + + {{ + tourStateMeta.revenueSum + | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' + }} + `, + styles: [ + ` + :host { + @apply flex flex-col items-center justify-center p-2; + } + `, + ], + imports: [NgIf, AsyncPipe, CurrencyPipe], +}) +export class AccountingLaneFooterComponent { + @Input() tourStateMeta!: TourStateMeta; + @Input() withTourCount = true; + @Input() withRevenueSum = false; +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-header-filter.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-header-filter.component.ts new file mode 100644 index 0000000..6ae6673 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-header-filter.component.ts @@ -0,0 +1,156 @@ +import { NgFor, NgSwitch, NgSwitchCase } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core'; +import { FormsModule, NgForm } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { debounceTime, map, tap } from 'rxjs'; +import { + AccountingFilterArgs, + AccountingState, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-lane-header-filter', + template: ` + + + + + +
+ +
+ {{ + item.text + }} +
+
+ +
+ {{ + item.text + }} +
+
+
+ `, + styles: [], + imports: [ + NgSwitch, + NgSwitchCase, + NgFor, + FormsModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatCheckboxModule, + ], +}) +export class AccountingLaneHeaderFilterComponent implements AfterViewInit { + @Input() state!: AccountingState; + @Output() filterChange = new EventEmitter(); + @ViewChild('form') form!: NgForm; + + AccountingState = AccountingState; + + ngAfterViewInit() { + this.form.valueChanges + ?.pipe( + // debounce to avoid multiple calls during form initialisation + debounceTime(10), + map((form): AccountingFilterArgs => { + return Object.entries(form) + .filter(([, value]) => value) + .map(([key]) => key) + .reduce((acc, key) => { + const [property, value] = key.split('.'); + return { + ...acc, + [property]: [...(acc?.[property] || []), value], + }; + }, {} as Record); + }), + // filter((obj) => Object.keys(obj).length > 0), + tap((form) => this.filterChange.emit(form)) + ) + .subscribe(); + } + + filter = { + beforeBilled: [ + { + checked: false, + value: 'deviation.both', + text: 'Abweichungen in Abrechnungsbeträgen', + }, + { + checked: false, + value: 'missing.information', + text: 'Fehlende Informationen', + }, + { + checked: false, + value: 'missing.documents', + text: 'Fehlende Dokumente', + }, + ], + afterBilled: [ + { + checked: false, + value: 'deviation.both', + text: 'alle Abweichungen', + }, + { + checked: false, + value: 'deviation.neg', + text: 'negative Abweichungen', + }, + { + checked: false, + value: 'deviation.pos', + text: 'positive Abweichungen', + }, + ], + }; +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-header.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-header.component.ts new file mode 100644 index 0000000..d0f743b --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane-header.component.ts @@ -0,0 +1,48 @@ +import { NgSwitch, NgSwitchCase } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; +import { AccountingLaneHeaderFilterComponent } from './accounting-lane-header-filter.component'; +import { AccountingFilterArgs, AccountingState } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-lane-header', + template: `
+ +
Abrechenbar
+
+ für Abrechnung vorbereitet +
+
+ Abgerechnet (mit Abweichung) +
+
+ `, + styles: [ + ` + :host { + @apply flex items-center justify-between p-2 bg-gray-50; + } + `, + ], + imports: [NgSwitch, NgSwitchCase, AccountingLaneHeaderFilterComponent], +}) +export class AccountingLaneHeaderComponent { + @Input() state!: AccountingState; + @Output() filterChange = new EventEmitter(); + + AccountingState = AccountingState; + + filterChanged(filter: AccountingFilterArgs) { + this.filterChange.emit(filter); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane.component.html b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane.component.html new file mode 100644 index 0000000..6a14f24 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane.component.html @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane.component.ts new file mode 100644 index 0000000..12ee736 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-lane/accounting-lane.component.ts @@ -0,0 +1,200 @@ +import { + CdkVirtualScrollViewport, + ScrollingModule, +} from '@angular/cdk/scrolling'; +import { AsyncPipe, CurrencyPipe, NgClass, NgFor, NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + ViewChild, + inject, +} from '@angular/core'; +import { DialogService } from '@ngneat/dialog'; +import { + BehaviorSubject, + Observable, + combineLatest, + debounceTime, + filter, + map, + mergeMap, + scan, + switchMap, + tap, +} from 'rxjs'; +import { + AccountingFilterArgs, + AccountingState, + Ticket, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; +import { AccountingTourViewComponent } from '../accounting-tour-view/accounting-tour-view.component'; +import { DashboardAccountingService } from '../dashboard-accounting.service'; +import { AccountingItemComponent } from './accounting-item.component'; +import { AccountingLaneFooterComponent } from './accounting-lane-footer.component'; +import { AccountingLaneHeaderComponent } from './accounting-lane-header.component'; + +@Component({ + selector: 'dks-accounting-lane', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './accounting-lane.component.html', + styles: [ + ` + :host { + @apply rounded bg-gray-200 h-full w-full grid; + grid-template-rows: auto 1fr auto; + } + `, + ], + imports: [ + AccountingLaneHeaderComponent, + AccountingLaneFooterComponent, + AccountingItemComponent, + NgFor, + NgIf, + NgClass, + AsyncPipe, + CurrencyPipe, + ScrollingModule, + ], +}) +export class AccountingLaneComponent { + private dialog = inject(DialogService); + private accountingService = inject(DashboardAccountingService); + + private accountingState$ = new BehaviorSubject( + undefined + ); + private accountingFilter$ = new BehaviorSubject({}); + + @Input() set accountingState(accountingState: AccountingState) { + this.accountingState$.next(accountingState); + } + get accountingState() { + return this.accountingState$.getValue() as AccountingState; + } + + @ViewChild(CdkVirtualScrollViewport) + viewport!: CdkVirtualScrollViewport; + + AccountingState = AccountingState; + + take = 20; + hasNextPage = true; + nextPage = ''; + + cursor$ = new BehaviorSubject(''); + accountingStateMeta$ = this.accountingState$.pipe( + filter(Boolean), + switchMap((accountingState) => + this.accountingService.fetchAccountingStateMeta(accountingState) + ) + ); + tours$: Observable; + + constructor() { + const batchMap: Observable> = + combineLatest([this.cursor$, this.accountingFilter$]).pipe( + debounceTime(200), + mergeMap(([cursor, filter]) => + this.fetchTours(cursor, filter).pipe( + map((tours) => ({ tours, cursor })) + ) + ), + scan((acc, { tours, cursor }) => { + const normalizedCursor = this.getNormalizedCursor(cursor); + const existingTours = Object.entries(acc) + .filter( + ([id]) => + id.split('_').at(0) !== normalizedCursor || + Object.keys(tours).includes(id.split('_').at(1) ?? '') + ) + .reduce( + (acc, [id, tour]) => ({ + ...acc, + [`${normalizedCursor}_${id}`]: tour, + }), + {} as Record + ); + + return { ...existingTours, ...tours }; + }, {} as Record) + ); + this.tours$ = batchMap.pipe( + map((tourNodes) => + Object.values(tourNodes).sort( + ({ createdAt: aCreated }, { createdAt: bCreated }) => + new Date(bCreated).getTime() - new Date(aCreated).getTime() + ) + ) + ); + } + + openTourView(tour: Tour & { ticket: Ticket }): void { + const dialogRef = this.dialog.open(AccountingTourViewComponent, { + data: tour, + width: '60vw', + minHeight: '400px', + }); + dialogRef.afterClosed$ + .pipe(tap((result) => result && this.openTourView(result))) + .subscribe(); + } + + trackByFn(index: number, tour: Tour): string { + return tour.id; + } + + fetchNextPage() { + if (!this.hasNextPage) { + return; + } + const start = this.viewport.getRenderedRange().start; + const end = this.viewport.getRenderedRange().end; + const total = this.viewport.getDataLength(); + + if (this.cursor$.getValue() !== '' && start === 0) { + this.cursor$.next(''); + } else if (total - end < 5) { + this.cursor$.next(this.nextPage); + } + } + + private fetchTours( + cursor: string, + filter: AccountingFilterArgs + ): Observable> { + const normalizedCursor = this.getNormalizedCursor(cursor); + + return this.accountingService + .fetchToursByState(this.accountingState, cursor, filter, this.take) + .pipe( + tap(({ endCursor, hasNextPage }) => { + this.nextPage = endCursor; + this.hasNextPage = hasNextPage; + }), + map(({ nodes }) => + nodes?.reduce( + (acc, cur) => ({ + ...acc, + [`${normalizedCursor}_${cur.id}`]: cur as Tour & { + ticket: Ticket; + }, + }), + {} as Record + ) + ) + ); + } + + private getNormalizedCursor(cursor: string) { + return cursor === '' ? 'MA==' : cursor; + } + + filterChanged(filter: AccountingFilterArgs) { + this.accountingFilter$.next(filter); + this.cursor$.next(''); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-overview.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-overview.component.ts new file mode 100644 index 0000000..370e099 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-overview.component.ts @@ -0,0 +1,79 @@ +import { DatePipe, NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { Tour } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-tour-overview', + template: ` +
+
+ Startzeit + + {{ tour.startDate | date : 'dd.MM.yyyy' }} + {{ tour.startDate | date : 'HH:mm' }} Uhr + +
+
+ Erstellt am + + {{ tour.createdAt | date : 'dd.MM.yyyy' }} + {{ tour.createdAt | date : 'HH:mm' }} Uhr + +
+
+ +
+
+ Fahrzeug + {{ tour.carName }} +
+
+ Fahrer + {{ tour.driverName }} +
+
+ Beifahrer + {{ tour.codriverName }} +
+
+
+ Patient + {{ tour.patientName }} {{ tour.patientSurname }} + {{ tour.patientStreet }} + {{ tour.patientZip }} {{ tour.patientCity }} +
+
+
+ Start + {{ tour.startInstitution }} + {{ tour.startStreet }} + {{ tour.startZip }} {{ tour.startCity }} +
+
+ Ziel + {{ tour.targetInstitution }} + {{ tour.targetStreet }} + {{ tour.targetZip }} {{ tour.targetCity }} +
+
+ `, + styles: [], + imports: [MatTabsModule, MatIconModule, MatDividerModule, NgIf, DatePipe], +}) +export class AccountingTourOverviewComponent { + @Input() tour!: Tour; +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-ticket.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-ticket.component.ts new file mode 100644 index 0000000..5de8065 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-ticket.component.ts @@ -0,0 +1,106 @@ +import { DatePipe, NgFor, NgIf, NgSwitch, NgSwitchCase } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { Ticket, TicketValidationState, Tour } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-tour-ticket', + template: ` +
+ +
Ticket Informationen
+ + Status: + + Touren angenommen + + Genehmigung / Transportschein + + Transportschein unklar + + Dokumente fehlen + + Archiviert + + + + + Typ: + + {{ tour.type }} + + + + Kategorie: + + {{ tour.category }} + + +
+ +
Ticket Anmerkungen
+ + error + {{ error.split(':').at(0) }} + {{ + error.split(':').at(1) + }} + + + warning + {{ note.split(':').at(0) }} + {{ + note.split(':').at(1) + }} + +
+
+ `, + styles: [], + imports: [ + MatIconModule, + MatListModule, + MatDividerModule, + NgIf, + NgFor, + NgSwitch, + NgSwitchCase, + DatePipe, + ], +}) +export class AccountingTourTicketComponent { + @Input() tour!: Tour & { ticket: Ticket }; + + TicketValidationState = TicketValidationState; +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-view.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-view.component.ts new file mode 100644 index 0000000..7e996b0 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/accounting-tour-view/accounting-tour-view.component.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { DialogRef } from '@ngneat/dialog'; +import { + Ticket, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; +import { AccountingTourOverviewComponent } from './accounting-tour-overview.component'; +import { AccountingTourTicketComponent } from './accounting-tour-ticket.component'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-accounting-tour-view', + template: `

+ {{ ref.data.ordinanceType }} - + {{ ref.data.patientName }} + {{ ref.data.patientSurname }} +

+
+ + + + + + + + `, + styles: [], + imports: [ + MatTabsModule, + MatIconModule, + AccountingTourOverviewComponent, + AccountingTourTicketComponent, + ], +}) +export class AccountingTourViewComponent { + ref: DialogRef = + inject(DialogRef); +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.component.ts b/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.component.ts new file mode 100644 index 0000000..45c19c3 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { AccountingLaneComponent } from './accounting-lane/accounting-lane.component'; +import { AccountingState } from '../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-dashboard-accounting', + standalone: true, + imports: [AccountingLaneComponent], + template: `
+
+ +
+ + + +
`, + styles: [ + ` + :host { + display: block; + height: calc(100% - 64px); + } + section { + grid-template-rows: 2rem 1fr; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DashboardAccountingComponent { + AccountingState = AccountingState; +} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.module.ts b/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.module.ts new file mode 100644 index 0000000..952fbf9 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.module.ts @@ -0,0 +1,15 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { DashboardAccountingComponent } from './dashboard-accounting.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: DashboardAccountingComponent + } + ]) + ], +}) +export class DashboardAccountingModule {} diff --git a/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.service.ts b/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.service.ts new file mode 100644 index 0000000..ce132e9 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/accounting/dashboard-accounting.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@angular/core'; +import { HotToastService } from '@ngxpert/hot-toast'; +import { + Observable, + Subject, + catchError, + combineLatest, + map, + retry, + shareReplay, + startWith, + switchMap, + tap, + throwError, + timer, +} from 'rxjs'; +import { + AccountingFilterArgs, + AccountingState, + GetAccountingStateMetaGQL, + GetAccountingStateMetaQuery, + GetAccountingToursGQL, + GetAccountingToursQuery, +} from '../../core/data-access/graphql/generated/generated'; + +@Injectable({ providedIn: 'root' }) +export class DashboardAccountingService { + toursUpdated$ = timer(60 * 1000).pipe( + tap((value) => { + if (value) { + this.toast.info('Daten wurden aktualisiert', { + id: 'update', + }); + } + }), + shareReplay(1), + startWith(undefined) + ); + + updateTours$ = new Subject(); + + constructor( + private readonly getAccountingToursGql: GetAccountingToursGQL, + private readonly getAccountingStateMetaGql: GetAccountingStateMetaGQL, + private readonly toast: HotToastService + ) { + this.updateTours$.next(); + } + + fetchToursByState( + state: AccountingState, + cursor: string, + filters: AccountingFilterArgs, + take = 20 + ): Observable { + const ticketsQuery = this.getAccountingToursGql.watch({ + state, + cursor, + filters, + take, + }); + + const ticketsUpdate = combineLatest([ + this.toursUpdated$, + this.updateTours$, + ]).pipe( + switchMap(() => ticketsQuery.refetch()), + startWith(undefined) + ); + + return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 1000, count: 3 }), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => response.data.accountingTours) + ); + } + + fetchAccountingStateMeta( + state: AccountingState + ): Observable { + const ticketStateMetaQuery = this.getAccountingStateMetaGql.watch({ + state, + }); + + const toursUpdate = combineLatest([ + this.toursUpdated$, + this.updateTours$, + ]).pipe( + switchMap(() => ticketStateMetaQuery.refetch()), + startWith(undefined) + ); + + return combineLatest([toursUpdate, ticketStateMetaQuery.valueChanges]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 1000, count: 3 }), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => response.data.accountingStateMeta) + ); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomalies.service.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomalies.service.ts new file mode 100644 index 0000000..0bd3cc9 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomalies.service.ts @@ -0,0 +1,134 @@ +import { EventEmitter, Injectable } from '@angular/core'; +import { HotToastService } from '@ngxpert/hot-toast'; +import { catchError, delay, EMPTY, map, Observable, take, tap } from 'rxjs'; +import { + GetAnomaliesGQL, + GetGroupedAnomaliesGQL, + SetAnomalyToSleepGQL, + AcceptAnomalyGQL, + GetUpcomingToursGQL, + PaginatedAnomaly, + PaginatedAnomalyGroup, +} from '../../core/data-access/graphql/generated/generated'; +import { faro, LogLevel } from '@grafana/faro-web-sdk'; + +@Injectable() +export class AnomaliesService { + anomaliesRefresh$ = new EventEmitter(); + upcomingToursQuery; + upcomingTours$; + + constructor( + private readonly getAnomaliesGql: GetAnomaliesGQL, + private readonly getGroupedAnomaliesGql: GetGroupedAnomaliesGQL, + private readonly setAnomalyToSleepGql: SetAnomalyToSleepGQL, + private readonly acceptAnomalyGql: AcceptAnomalyGQL, + private readonly upcomingToursGql: GetUpcomingToursGQL, + private readonly toast: HotToastService + ) { + this.upcomingToursQuery = this.upcomingToursGql.watch( + {}, + { pollInterval: 60 * 1000 } + ); + + this.upcomingTours$ = this.upcomingToursQuery.valueChanges; + } + + getAnomaliesByPage( + cursor: string, + take: number = 10 + ): Observable { + return this.getAnomaliesGql + .fetch( + { cursor, take }, + { + fetchPolicy: 'no-cache', + } + ) + .pipe( + map((queryResult) => queryResult.data.anomalies), + catchError((err) => { + console.error(err); + return EMPTY; + }) + ); + } + + getGroupedAnomaliesByPage( + cursor: string, + take: number = 10 + ): Observable { + return this.getGroupedAnomaliesGql + .fetch( + { cursor, take }, + { + fetchPolicy: 'no-cache', + } + ) + .pipe( + map( + (queryResult) => + queryResult.data + .groupedAnomalies as unknown as PaginatedAnomalyGroup + ), + catchError((err) => { + console.error(err); + return EMPTY; + }) + ); + } + + setAnomalyToSleep(anomalyId: string): void { + this.setAnomalyToSleepGql + .mutate({ id: anomalyId }) + .pipe( + take(1), + tap(() => this.anomaliesRefresh$.emit()), + tap((mutationResult) => { + if (mutationResult.errors) { + this.toast.error( + 'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal' + ); + } else { + faro.api.pushLog([`Sleep anomaly ${anomalyId}`], { + context: { + payload: anomalyId, + }, + level: LogLevel.TRACE, + }) + this.toast.success( + 'Die Anomalie wurde für eine Stunde stummgeschaltet!' + ); + } + }) + ) + .subscribe(); + } + + acceptAnomaly(anomalyId: string): void { + + this.acceptAnomalyGql + .mutate({ id: anomalyId }) + .pipe( + take(1), + delay(10), + tap(() => this.anomaliesRefresh$.emit()), + tap((mutationResult) => { + if (mutationResult.errors) { + this.toast.error( + 'Leider ist etwas schief gelaufen, versuchen Sie es bitte nochmal' + ); + } else { + faro.api.pushLog([`Accept anomaly ${anomalyId}`], { + context: { + payload: anomalyId, + }, + level: LogLevel.TRACE, + }) + this.toast.success('Die Anomalie wurde akzeptiert!'); + } + }) + ) + .subscribe(); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.css b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.css new file mode 100644 index 0000000..ec03003 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.css @@ -0,0 +1,3 @@ +:host { + @apply w-full rounded flex items-center; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.html b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.html new file mode 100644 index 0000000..644eaa0 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.html @@ -0,0 +1,3 @@ + + {{ anomaly.description }} + diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.spec.ts new file mode 100644 index 0000000..979c3b3 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnomalyListItemComponent } from './anomaly-list-item.component'; + +describe('AnomalyListItemComponent', () => { + let component: AnomalyListItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AnomalyListItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AnomalyListItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.ts new file mode 100644 index 0000000..6acfaf7 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list-item/anomaly-list-item.component.ts @@ -0,0 +1,97 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + HostBinding, + HostListener, + Input, + Output, +} from '@angular/core'; +import { DialogService } from '@ngneat/dialog'; + +import { AnomalySolutionDialogComponent } from '../anomaly-solution-dialog/anomaly-solution-dialog.component'; +import { ConfirmDialogComponent } from '../../../../core/components/confirm-dialog/confirm-dialog.component'; + +interface Anomaly { + __typename?: 'Anomaly' | undefined; + id: string; + type: string; + description: string; + sleepTimer: number; + sleepSince?: Date; + workedOnBy: string; + resolvedAt?: Date; + createdAt: Date; + solution: { + __typename?: 'SolutionTarget' | undefined; + name: string; + steps: { + description: string; + }[]; + }[]; +} + +@Component({ + selector: 'dks-anomaly-list-item', + templateUrl: './anomaly-list-item.component.html', + styleUrls: ['./anomaly-list-item.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnomalyListItemComponent { + isHovering = false; + + @Input() anomaly!: Anomaly; + @Output() anomalySetToSleep = new EventEmitter(); + @Output() anomalyAccepted = new EventEmitter(); + + @HostListener('mouseenter') + onMouseEnter() { + this.isHovering = true; + } + + @HostListener('mouseleave') + onMouseLeave() { + this.isHovering = false; + } + + @HostBinding('class') + private get hostClass(): string { + return this.isHovering ? 'mat-elevation-z1' : ''; + } + + constructor(private readonly dialog: DialogService) {} + + setAnomalyToSleep(id: string) { + this.anomalySetToSleep.emit(id); + } + + acceptAnomaly(id: string) { + this.dialog + .open(ConfirmDialogComponent, { + data: { + title: 'Ist dies kein Fehler?', + body: 'Sind sie sicher, dass diese Anomalie kein Fehler ist? Dann bestätigen Sie diesen Dialog', + }, + width: 500 + }) + .afterClosed$.subscribe( + (confirmed) => confirmed && this.anomalyAccepted.emit(id) + ); + } + + showSolutionDialog(): void { + const dialogRef = this.dialog.open(AnomalySolutionDialogComponent, { + data: { + title: this.anomaly.description, + solution: this.anomaly.solution, + }, + }); + dialogRef.afterClosed$.subscribe((result) => { + if (result === 'sleep') { + this.setAnomalyToSleep(this.anomaly.id); + } else if (result === 'accept') { + this.acceptAnomaly(this.anomaly.id); + } + }); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.css b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.css new file mode 100644 index 0000000..9b724b9 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex flex-col h-full; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.html b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.html new file mode 100644 index 0000000..4a69013 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.html @@ -0,0 +1,29 @@ + + + + + + + + Alle Mitarbeiter sind auf einem Fahrzeug angemeldet! + + + + diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.spec.ts new file mode 100644 index 0000000..7366ba5 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnomalyListComponent } from './anomaly-list.component'; + +describe('AnomalyListComponent', () => { + let component: AnomalyListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AnomalyListComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AnomalyListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.ts new file mode 100644 index 0000000..67f0cff --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-list.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { + BehaviorSubject, + map, + shareReplay, + Subscription, + switchMap, + tap +} from 'rxjs'; + +import { AnomaliesService } from '../anomalies.service'; + +@Component({ + selector: 'dks-anomaly-list', + templateUrl: './anomaly-list.component.html', + styleUrls: ['./anomaly-list.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnomalyListComponent implements OnInit, OnDestroy { + @ViewChild(MatPaginator, { static: true }) private paginator!: MatPaginator; + + private prevCursor = ''; + private nextCursor = ''; + private subscription = new Subscription(); + private cursor$ = new BehaviorSubject(this.nextCursor); + private paginatedAnomalies$ = this.cursor$.pipe( + tap((cursor) => { + if (!cursor) { + this.prevCursor = ''; + this.nextCursor = ''; + this.paginator.firstPage(); + } + }), + switchMap((cursor) => + this.anomaliesService.getAnomaliesByPage(cursor, this.pageSize) + ), + tap(({ prevCursor, endCursor }) => { + this.prevCursor = prevCursor; + this.nextCursor = endCursor; + }), + shareReplay(1) + ); + anomalies$ = this.paginatedAnomalies$.pipe(map(({ nodes }) => nodes)); + totalCount$ = this.paginatedAnomalies$.pipe( + map(({ totalCount }) => totalCount) + ); + + pageSize = 10; + + constructor(private readonly anomaliesService: AnomaliesService) {} + + ngOnInit(): void { + this.subscription.add( + this.anomaliesService.anomaliesRefresh$ + .pipe(tap(() => this.cursor$.next(''))) + .subscribe() + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + setAnomalyToSleep(anomalyId: string): void { + this.anomaliesService.setAnomalyToSleep(anomalyId); + } + + acceptAnomaly(anomalyId: string): void { + this.anomaliesService.acceptAnomaly(anomalyId); + } + + changePage({ pageIndex, previousPageIndex }: PageEvent): void { + if (pageIndex > (previousPageIndex ?? 0)) { + this.cursor$.next(this.nextCursor); + } else { + this.cursor$.next(this.prevCursor); + } + } +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.css b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.css new file mode 100644 index 0000000..04bc910 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.css @@ -0,0 +1,3 @@ +:host { + @apply m-4; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.html b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.html new file mode 100644 index 0000000..f8b19a8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.html @@ -0,0 +1,21 @@ +

{{ ref.data.title }}

+ +
+
+ {{ target.name }} +
    +
  • + {{ step.description }} +
  • +
+
+
+ +
+ + +
diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.spec.ts new file mode 100644 index 0000000..30bb4e1 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnomalySolutionDialogComponent } from './anomaly-solution-dialog.component'; + +describe('AnomalySolutionDialogComponent', () => { + let component: AnomalySolutionDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AnomalySolutionDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AnomalySolutionDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.ts new file mode 100644 index 0000000..79e4102 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component.ts @@ -0,0 +1,20 @@ +import { Component } from '@angular/core'; +import { DialogRef } from '@ngneat/dialog'; + +interface SolutionTarget { + name: string; + steps: { + description: string; + }[]; +} + +@Component({ + selector: 'dks-anomaly-solution-dialog', + templateUrl: './anomaly-solution-dialog.component.html', + styleUrls: ['./anomaly-solution-dialog.component.css'], +}) +export class AnomalySolutionDialogComponent { + constructor( + public ref: DialogRef<{ title: string; solution: SolutionTarget[] }, 'sleep' | 'accept'> + ) {} +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.css b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.css new file mode 100644 index 0000000..9b724b9 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex flex-col h-full; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.html b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.html new file mode 100644 index 0000000..958d758 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.html @@ -0,0 +1,48 @@ + + + + + + + + + {{ node.name }} +
{{ node?.childCount }}
+
+
+ + Herzlichen Glückwunsch, es liegen keine Anomalien vor! + +
+ + diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.spec.ts new file mode 100644 index 0000000..1900f2d --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AnomalyTreeComponent } from './anomaly-tree.component'; + +describe('AnomalyTreeComponent', () => { + let component: AnomalyTreeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [AnomalyTreeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AnomalyTreeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.ts new file mode 100644 index 0000000..6068037 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/anomaly-tree/anomaly-tree.component.ts @@ -0,0 +1,152 @@ +/* eslint-disable @typescript-eslint/member-ordering */ +import { FlatTreeControl } from '@angular/cdk/tree'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { + MatTreeFlatDataSource, + MatTreeFlattener +} from '@angular/material/tree'; +import { + BehaviorSubject, + map, + shareReplay, + Subscription, + switchMap, + tap +} from 'rxjs'; + +import { AnomaliesService } from '../anomalies.service'; +import { Anomaly } from '../../../core/data-access/graphql/generated/generated'; + +interface AnomalyNode { + name: string; + anomalyId: string; + anomaly: Anomaly; + children?: AnomalyNode[]; +} + +interface AnomalyFlatNode { + childCount: number; + expandable: boolean; + name: string; + anomalyId: string; + anomaly: Anomaly; + level: number; +} + +@Component({ + selector: 'dks-anomaly-tree', + templateUrl: './anomaly-tree.component.html', + styleUrls: ['./anomaly-tree.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AnomalyTreeComponent implements OnInit, OnDestroy { + @ViewChild(MatPaginator, { static: true }) private paginator!: MatPaginator; + pageSize = 10; + private _transformer = (node: AnomalyNode, level: number) => { + return { + childCount: node.children?.length || 0, + expandable: !!node.children && node.children.length > 0, + name: node.name, + level: level, + anomalyId: node.anomalyId, + anomaly: node.anomaly, + }; + }; + + treeControl = new FlatTreeControl( + (node) => node.level, + (node) => node.expandable + ); + + treeFlattener = new MatTreeFlattener( + this._transformer, + (node) => node.level, + (node) => node.expandable, + (node) => node.children + ); + + private prevCursor = ''; + private nextCursor = ''; + private subscription = new Subscription(); + private cursor$ = new BehaviorSubject(this.nextCursor); + private paginatedGroupedAnomalies$ = this.cursor$.pipe( + tap((cursor) => { + if (!cursor) { + this.prevCursor = ''; + this.nextCursor = ''; + this.paginator.firstPage(); + } + }), + switchMap((cursor) => + this.anomaliesService.getGroupedAnomaliesByPage(cursor, this.pageSize) + ), + tap(({ prevCursor, endCursor }) => { + this.prevCursor = prevCursor; + this.nextCursor = endCursor; + }), + shareReplay(1) + ); + + totalCount$ = this.paginatedGroupedAnomalies$.pipe( + map(({ totalCount }) => totalCount) + ); + + anomalies$ = this.paginatedGroupedAnomalies$.pipe( + map(({ nodes }) => + nodes?.map(({ anomalies }) => ({ + name: anomalies[0].groupDescription, + anomalyId: '', + anomaly: {} as Anomaly, + children: anomalies.map((anomaly) => ({ + name: anomaly.description, + anomalyId: anomaly.id, + anomaly, + })), + })) + ), + tap((groupedAnomalies) => (this.dataSource.data = groupedAnomalies ?? [])) + ); + + dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + constructor(private readonly anomaliesService: AnomaliesService) { + this.dataSource.data; + } + + ngOnInit(): void { + this.subscription.add( + this.anomaliesService.anomaliesRefresh$ + .pipe(tap(() => this.cursor$.next(''))) + .subscribe() + ); + } + + ngOnDestroy(): void { + this.subscription.unsubscribe(); + } + + setAnomalyToSleep(anomalyId: string): void { + this.anomaliesService.setAnomalyToSleep(anomalyId); + } + + acceptAnomaly(anomalyId: string): void { + this.anomaliesService.acceptAnomaly(anomalyId); + } + + changePage({ pageIndex, previousPageIndex }: PageEvent): void { + if (pageIndex > (previousPageIndex ?? 0)) { + this.cursor$.next(this.nextCursor); + } else { + this.cursor$.next(this.prevCursor); + } + } + + hasChild = (_: number, node: AnomalyFlatNode) => node.expandable; +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.html b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.html new file mode 100644 index 0000000..fc99a23 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.html @@ -0,0 +1,42 @@ +
+
+ + + +
+
+ + + Anomalien + + + + + + + + Mitarbeiter ohne Tour + + + + + + + + Nächste Touren ohne Anfahrt + + + + + +
+
diff --git a/service/app-hub/.gitkeep b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.scss similarity index 100% rename from service/app-hub/.gitkeep rename to service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.scss diff --git a/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.spec.ts new file mode 100644 index 0000000..f01db34 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ControlCenterDashboardComponent } from './control-center-dashboard.component'; + +describe('ControlCenterDashboardComponent', () => { + let component: ControlCenterDashboardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ControlCenterDashboardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ControlCenterDashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.ts new file mode 100644 index 0000000..9a0426b --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/control-center-dashboard/control-center-dashboard.component.ts @@ -0,0 +1,9 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'dks-control-center-dashboard', + templateUrl: './control-center-dashboard.component.html', + styleUrls: ['./control-center-dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ControlCenterDashboardComponent {} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/dashboard-control-center.module.ts b/service/app-hub/dashboard/src/app/pages/control-center/dashboard-control-center.module.ts new file mode 100644 index 0000000..8697bd3 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/dashboard-control-center.module.ts @@ -0,0 +1,56 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatGridListModule } from '@angular/material/grid-list'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatTreeModule } from '@angular/material/tree'; +import { RouterModule } from '@angular/router'; +import { AnomaliesService } from './anomalies.service'; +import { AnomalyListItemComponent } from './anomaly-list/anomaly-list-item/anomaly-list-item.component'; +import { AnomalyListComponent } from './anomaly-list/anomaly-list.component'; +import { AnomalySolutionDialogComponent } from './anomaly-list/anomaly-solution-dialog/anomaly-solution-dialog.component'; +import { AnomalyTreeComponent } from './anomaly-tree/anomaly-tree.component'; +import { ControlCenterDashboardComponent } from './control-center-dashboard/control-center-dashboard.component'; +import { ToursPerTimeKPIComponent } from './kpis/tours-per-time-kpi/tours-per-time-kpi.component'; +import { TourViewDialogComponent } from './upcoming-tours/tour-view-dialog/tour-view-dialog.component'; +import { UpcomingToursComponent } from './upcoming-tours/upcoming-tours.component'; +import { ConfirmDialogComponent } from '../../core/components/confirm-dialog/confirm-dialog.component'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild([ + { + path: '', + component: ControlCenterDashboardComponent, + }, + ]), + MatGridListModule, + MatCardModule, + MatIconModule, + MatMenuModule, + MatListModule, + MatButtonModule, + MatDividerModule, + MatTreeModule, + MatPaginatorModule, + ConfirmDialogComponent, + ], + declarations: [ + ControlCenterDashboardComponent, + AnomalyListComponent, + AnomalyListItemComponent, + AnomalySolutionDialogComponent, + AnomalyTreeComponent, + UpcomingToursComponent, + ToursPerTimeKPIComponent, + TourViewDialogComponent, + ], + providers: [AnomaliesService], +}) +export class DashboardControlCenterModule {} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.css b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.html b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.html new file mode 100644 index 0000000..a56d59e --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.html @@ -0,0 +1,24 @@ + + + {{ ordinanceType }} + + +
+
+
+ {{ kpi.kpi.value.toString().replace('.', ',') }} +
+
+ {{ kpi.interval.start | date: 'HH:mm' }} - + {{ kpi.interval.end | date: 'HH:mm' }} +
+
+
+
+
diff --git a/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.spec.ts new file mode 100644 index 0000000..f1e250d --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToursPerTimeKPIComponent } from './tours-per-time-kpi.component'; + +describe('ToursPerTimeKPIComponent', () => { + let component: ToursPerTimeKPIComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ToursPerTimeKPIComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ToursPerTimeKPIComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.ts new file mode 100644 index 0000000..bd2a886 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/kpis/tours-per-time-kpi/tours-per-time-kpi.component.ts @@ -0,0 +1,57 @@ +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit, +} from '@angular/core'; +import { isWithinInterval } from 'date-fns'; +import { combineLatest, interval, map, Observable, startWith } from 'rxjs'; +import { + GetTourAmountPerTimeKpiGQL, + GetTourAmountPerTimeKpiQuery, +} from '../../../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-tours-per-time-kpi', + templateUrl: './tours-per-time-kpi.component.html', + styleUrls: ['./tours-per-time-kpi.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ToursPerTimeKPIComponent implements OnInit { + @Input() ordinanceType!: string; + + toursPerTimeKPI$!: Observable< + GetTourAmountPerTimeKpiQuery['toursPerTimeKPI'] + >; + currentTimeSlot$!: Observable; + + constructor( + private readonly getToursPerTimeKPIGql: GetTourAmountPerTimeKpiGQL + ) {} + + ngOnInit(): void { + this.toursPerTimeKPI$ = this.getToursPerTimeKPIGql + .watch({ ordinanceType: this.ordinanceType }, { pollInterval: 60000 }) + .valueChanges.pipe(map((result) => result.data.toursPerTimeKPI)); + this.currentTimeSlot$ = combineLatest([ + interval(60000).pipe(startWith(0)), + this.toursPerTimeKPI$, + ]).pipe( + map(([, kpis]) => + kpis.findIndex((kpi) => + this.isInInterval({ + start: kpi.interval.start, + end: kpi.interval.end, + }) + ) + ) + ); + } + + isInInterval(interval: { start: string; end: string }): boolean { + return isWithinInterval(new Date(), { + start: new Date(interval.start), + end: new Date(interval.end), + }); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.css b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.css new file mode 100644 index 0000000..04bc910 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.css @@ -0,0 +1,3 @@ +:host { + @apply m-4; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.html b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.html new file mode 100644 index 0000000..f3b3c11 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.html @@ -0,0 +1,61 @@ +

{{ ref.data.operationId }} - {{ ref.data.ordinanceType }}

+
+
+ Startzeit + + {{ ref.data.startDate | date: 'HH:mm' }} Uhr + {{ ref.data.startDate | date: 'dd.MM.yyyy' }} + +
+
+ Erstellt am + + {{ ref.data.createdAt | date: 'HH:mm' }} Uhr + {{ ref.data.createdAt | date: 'dd.MM.yyyy' }} + +
+
+ +
+
+ Fahrzeug + {{ ref.data.carName }} +
+
+ Fahrer + {{ ref.data.driverName }} +
+
+ Beifahrer + {{ ref.data.codriverName }} +
+
+
+ Patient + {{ ref.data.patientName }} {{ ref.data.patientSurname }} + {{ ref.data.patientStreet }} + {{ ref.data.patientZip }} {{ ref.data.patientCity }} +
+
+
+ Start + {{ ref.data.startInstitution }} + {{ ref.data.startStreet }} + {{ ref.data.startZip }} {{ ref.data.startCity }} +
+
+ Ziel + {{ ref.data.targetInstitution }} + {{ ref.data.targetStreet }} + {{ ref.data.targetZip }} {{ ref.data.targetCity }} +
+
diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.spec.ts new file mode 100644 index 0000000..41246a1 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TourViewDialogComponent } from './tour-view-dialog.component'; + +describe('TourViewDialogComponent', () => { + let component: TourViewDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TourViewDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TourViewDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.ts new file mode 100644 index 0000000..9e1fd34 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/tour-view-dialog/tour-view-dialog.component.ts @@ -0,0 +1,12 @@ +import { Component, inject } from '@angular/core'; +import { DialogRef } from '@ngneat/dialog'; +import { Tour } from '../../../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-tour-view-dialog', + templateUrl: './tour-view-dialog.component.html', + styleUrls: ['./tour-view-dialog.component.css'], +}) +export class TourViewDialogComponent { + ref = inject>>(DialogRef); +} diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.css b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.html b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.html new file mode 100644 index 0000000..fa6caa5 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.html @@ -0,0 +1,19 @@ +
+ +
+
+
+ {{ tour.operationId }} - {{ tour.startDate | date: 'HH:mm' }} Uhr +
+
+
+ Keine Touren in den nächsten 20 Minuten ohne Anfahrt +
diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.spec.ts b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.spec.ts new file mode 100644 index 0000000..0ba401f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { UpcomingToursComponent } from './upcoming-tours.component'; + +describe('UpcomingToursComponent', () => { + let component: UpcomingToursComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [UpcomingToursComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(UpcomingToursComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.ts b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.ts new file mode 100644 index 0000000..64da730 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/control-center/upcoming-tours/upcoming-tours.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { DialogService } from '@ngneat/dialog'; +import { map } from 'rxjs'; + +import { AnomaliesService } from '../anomalies.service'; +import { TourViewDialogComponent } from './tour-view-dialog/tour-view-dialog.component'; +import { Tour } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-upcoming-tours', + templateUrl: './upcoming-tours.component.html', + styleUrls: ['./upcoming-tours.component.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpcomingToursComponent { + upcomingTours$; + + constructor( + private readonly anomaliesService: AnomaliesService, + private readonly diaolog: DialogService + ) { + this.upcomingTours$ = this.anomaliesService.upcomingTours$.pipe( + map((result) => result.data.upcomingTours), + map((tours) => + tours + .map((tour) => ({ + ...tour, + colorClass: + new Date(tour.startDate).getTime() - new Date().getTime() > + 10 * 60 * 1000 + ? 'bg-yellow-500' + : 'bg-red-500', + })) + .sort( + ({ startDate: startDateA }, { startDate: startDateB }) => + new Date(startDateA).getTime() - new Date(startDateB).getTime() + ) + ) + ); + } + + openTourViewDialog(tour: Partial) { + this.diaolog.open(TourViewDialogComponent, { + data: tour, + }); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/avicenna-web-feature-dashboard-manager.module.ts b/service/app-hub/dashboard/src/app/pages/manager/avicenna-web-feature-dashboard-manager.module.ts new file mode 100644 index 0000000..2e7698e --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/avicenna-web-feature-dashboard-manager.module.ts @@ -0,0 +1,88 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatDateFnsModule } from '@angular/material-date-fns-adapter'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatChipsModule } from '@angular/material/chips'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { MatTabsModule } from '@angular/material/tabs'; +import { RouterModule } from '@angular/router'; +import { de } from 'date-fns/locale'; +import { MetricSummaryComponent } from './components/metric-summary/metric-summary.component'; +import { DayviewComponent } from './dayview/dayview.component'; +import { ManagerDashboardComponent } from './manager-dashboard/manager-dashboard.component'; +import { MonthviewComponent } from './monthview/monthview.component'; +import { OverviewComponent } from './overview/overview.component'; +import { TimePerTourByCarComponent } from './time-per-tour-by-car/time-per-tour-by-car.component'; +import { TimePerTourKPIComponent } from './time-per-tour-kpi/time-per-tour-kpi.component'; +import { ViewTypeSelectorComponent } from './components/view-type-selector/view-type-selector.component'; +import { UtilNgxEchartsModule } from '../../core/components/ngx-echarts/util-ngx-echarts.module'; + +@NgModule({ + imports: [ + CommonModule, + RouterModule.forChild([ + { + path: '', + component: ManagerDashboardComponent, + children: [ + { + path: 'overview', + component: OverviewComponent, + }, + { + path: 'dayview', + component: DayviewComponent, + }, + { + path: 'monthview', + component: MonthviewComponent, + }, + { + path: '', + pathMatch: 'full', + redirectTo: 'overview', + }, + ], + }, + ]), + MatCardModule, + MatIconModule, + MatTableModule, + MatPaginatorModule, + MatSortModule, + MatDateFnsModule, + MatDatepickerModule, + MatButtonModule, + MatIconModule, + MatTabsModule, + MatChipsModule, + MatProgressBarModule, + ReactiveFormsModule, + UtilNgxEchartsModule, + MetricSummaryComponent, + ViewTypeSelectorComponent, + ], + declarations: [ + ManagerDashboardComponent, + TimePerTourKPIComponent, + TimePerTourByCarComponent, + OverviewComponent, + DayviewComponent, + MonthviewComponent, + ], + providers: [ + { + provide: MAT_DATE_LOCALE, + useValue: de, + }, + ], +}) +export class AvicennaWebFeatureDashboardManagerModule {} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.html new file mode 100644 index 0000000..fafedf8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.html @@ -0,0 +1,7 @@ +
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.spec.ts new file mode 100644 index 0000000..88b328a --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AverageRevenueMetricComponent } from './average-revenue-metric.component'; + +describe('AverageRevenueMetricComponent', () => { + let component: AverageRevenueMetricComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AverageRevenueMetricComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AverageRevenueMetricComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.ts new file mode 100644 index 0000000..019b085 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-metric/average-revenue-metric.component.ts @@ -0,0 +1,146 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnChanges } from '@angular/core'; +import { format } from 'date-fns'; +import { EChartsOption, LegendComponentOption } from 'echarts'; +import { BarChart, LineChart } from 'echarts/charts'; +import { + GridComponent, + LegendComponent, + TooltipComponent, +} from 'echarts/components'; +import { BehaviorSubject } from 'rxjs'; +import { UtilNgxEchartsModule } from '../../../../core/components/ngx-echarts/util-ngx-echarts.module'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-average-revenue-metric', + standalone: true, + imports: [CommonModule, UtilNgxEchartsModule], + templateUrl: './average-revenue-metric.component.html', + styleUrls: ['./average-revenue-metric.component.css'], +}) +export class AverageRevenueMetricComponent implements OnChanges { + @Input() metrics: TimePerTourMetric[] = []; + @Input() labelFormat!: string; + + echartsExtensions = [ + BarChart, + LineChart, + TooltipComponent, + GridComponent, + LegendComponent, + ]; + + echartsOptions$: BehaviorSubject = new BehaviorSubject( + this.generateEChartsOptions(this.metrics) + ); + + ngOnChanges(): void { + this.echartsOptions$.next(this.generateEChartsOptions(this.metrics)); + } + + private generateEChartsOptions(metrics: TimePerTourMetric[]): EChartsOption { + const legendData = ['Durchschnittsumsatz', 'Index']; + + const legendOptions: LegendComponentOption = { + orient: 'horizontal', + data: legendData, + }; + + return { + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + }, + grid: { + left: '15%', + width: '80%', + bottom: 50, + }, + legend: legendOptions, + xAxis: [ + { + type: 'category', + axisTick: { + show: true, + alignWithLabel: true, + }, + axisLabel: { + rotate: 30, + interval: 1, + fontSize: 10, + }, + data: metrics.map(({ interval: { start } }) => + format(new Date(start), this.labelFormat) + ), + }, + ], + yAxis: [ + { + type: 'value', + name: 'Betrag (EUR)', + position: 'left', + alignTicks: true, + axisLine: { + show: true, + }, + minInterval: 1, + axisLabel: { + fontSize: 10, + }, + }, + { + type: 'value', + name: 'Index', + position: 'right', + alignTicks: true, + axisLine: { + show: true, + }, + interval: 2, + min: 0, + max: 10, + axisLabel: { + fontSize: 10, + }, + }, + ], + series: [ + { + name: 'Index', + type: 'line', + data: metrics.map(({ tourAmount, availableTime }) => { + let value = 0; + if (tourAmount <= 0) { + value = availableTime / 0.1; + } else { + value = availableTime / tourAmount; + } + return { + value: value.toFixed(2), + itemStyle: { + borderWidth: 10, + }, + }; + }), + symbol: 'none', + symbolSize: 1, + smooth: true, + yAxisIndex: 1, + }, + { + name: 'Durchschnittsumsatz', + type: 'bar', + data: metrics.map(({ averageRevenue }) => averageRevenue.toFixed(2)), + itemStyle: { + borderRadius: [5, 5, 0, 0], + }, + barWidth: 8, + yAxisIndex: 0, + }, + ], + }; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.css new file mode 100644 index 0000000..95dedde --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex justify-center items-center; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.html new file mode 100644 index 0000000..5b72957 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.html @@ -0,0 +1,12 @@ +
+
+ {{ + summary.averageRevenue | currency : 'EUR' : 'symbol' : '1.2-2' + }} Ø Umsatz +
+
+ {{ + (summary.availableTime / summary.tourAmount) | number : '1.2-2' + }} Index +
+
\ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.spec.ts new file mode 100644 index 0000000..78d7555 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AverageRevenueSummaryComponent } from './average-revenue-summary.component'; + +describe('AverageRevenueSummaryComponent', () => { + let component: AverageRevenueSummaryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AverageRevenueSummaryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AverageRevenueSummaryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.ts new file mode 100644 index 0000000..55dbada --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/average-revenue-summary/average-revenue-summary.component.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { BehaviorSubject, map } from 'rxjs'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; +import { summarizeMetrics } from '../../util/summarize-metrics'; + +@Component({ + selector: 'dks-average-revenue-summary', + standalone: true, + imports: [CommonModule, MatIconModule], + templateUrl: './average-revenue-summary.component.html', + styleUrls: ['./average-revenue-summary.component.css'], +}) +export class AverageRevenueSummaryComponent { + @Input() set metrics(value: TimePerTourMetric[]) { + this._metrics$.next(value); + } + + private _metrics$ = new BehaviorSubject([]); + + metricsSummary$ = this._metrics$.pipe( + map((metrics) => summarizeMetrics(metrics)) + ); +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.html new file mode 100644 index 0000000..fafedf8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.html @@ -0,0 +1,7 @@ +
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.spec.ts new file mode 100644 index 0000000..b795dc8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BreakevenMetricComponent } from './breakeven-metric.component'; + +describe('BreakevenMetricComponent', () => { + let component: BreakevenMetricComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BreakevenMetricComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BreakevenMetricComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.ts new file mode 100644 index 0000000..8bef351 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-metric/breakeven-metric.component.ts @@ -0,0 +1,166 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnChanges } from '@angular/core'; +import { format } from 'date-fns'; +import { + EChartsOption, + LegendComponentOption, + LineSeriesOption, +} from 'echarts'; +import { BarChart, LineChart } from 'echarts/charts'; +import { + GridComponent, + LegendComponent, + TooltipComponent, +} from 'echarts/components'; +import { BehaviorSubject } from 'rxjs'; +import { UtilNgxEchartsModule } from '../../../../core/components/ngx-echarts/util-ngx-echarts.module'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; +import { MetricTimeScale } from '../../types'; + +@Component({ + selector: 'dks-breakeven-metric', + standalone: true, + imports: [CommonModule, UtilNgxEchartsModule], + templateUrl: './breakeven-metric.component.html', + styleUrls: ['./breakeven-metric.component.css'], +}) +export class BreakevenMetricComponent implements OnChanges { + @Input() metrics: TimePerTourMetric[] = []; + @Input() labelFormat!: string; + @Input() metricTimeScale!: MetricTimeScale; + + echartsExtensions = [ + BarChart, + LineChart, + TooltipComponent, + GridComponent, + LegendComponent, + ]; + + echartsOptions$: BehaviorSubject = new BehaviorSubject( + this.generateEChartsOptions(this.metrics) + ); + + ngOnChanges(): void { + this.echartsOptions$.next(this.generateEChartsOptions(this.metrics)); + } + + private generateEChartsOptions(metrics: TimePerTourMetric[]): EChartsOption { + const legendData = ['Deckungsbeitrag', 'Umsatz', 'Personal', 'Verbrauch']; + const yAxisName = 'Betrag (EUR)'; + + const legendOptions: LegendComponentOption = { + orient: 'horizontal', + data: legendData, + }; + + return { + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + }, + grid: { + left: '15%', + width: '80%', + bottom: 50, + }, + legend: legendOptions, + xAxis: [ + { + type: 'category', + axisTick: { + show: true, + alignWithLabel: true, + }, + axisLabel: { + rotate: 30, + interval: 1, + fontSize: 10, + }, + data: metrics.map(({ interval: { start } }) => + format(new Date(start), this.labelFormat) + ), + }, + ], + yAxis: [ + { + type: 'value', + name: yAxisName, + position: 'left', + alignTicks: true, + axisLine: { + show: true, + }, + minInterval: 1, + axisLabel: { + fontSize: 10, + }, + }, + ], + series: [ + { + name: 'Deckungsbeitrag', + type: 'line', + data: metrics.map(({ breakEven }) => { + return { + value: breakEven.toFixed(2), + itemStyle: { + borderWidth: 10, + }, + }; + }), + symbol: 'circle', + symbolSize: 7, + smooth: true, + }, + { + name: 'Umsatz', + type: 'bar', + data: metrics.map(({ revenue }) => revenue.toFixed(2)), + itemStyle: { + borderRadius: [5, 5, 0, 0], + }, + barWidth: 8, + }, + { + name: 'Personal', + type: 'bar', + data: metrics.map(({ personnelCost }) => personnelCost.toFixed(2)), + itemStyle: { + borderRadius: [5, 5, 0, 0], + }, + barWidth: 8, + }, + { + name: 'Verbrauch', + type: 'bar', + data: metrics.map(({ consumptionCost }) => + consumptionCost.toFixed(2) + ), + itemStyle: { + borderRadius: [2.5, 2.5, 0, 0], + }, + barWidth: 8, + }, + ...(this.metricTimeScale === MetricTimeScale.Monthview + ? ([ + { + name: 'Vorausschau Deckungsbeitrag', + type: 'line', + data: metrics.map(({ breakEven, forecastRevenue }) => + forecastRevenue > 0 + ? (breakEven + forecastRevenue).toFixed(2) + : undefined + ), + symbol: 'diamond', + symbolSize: 7, + smooth: true, + }, + ] as LineSeriesOption[]) + : []), + ], + }; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.css new file mode 100644 index 0000000..95dedde --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex justify-center items-center; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.html new file mode 100644 index 0000000..59451b6 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.html @@ -0,0 +1,44 @@ +
+
+ {{ + summary.revenue | currency : 'EUR' : 'symbol' : '1.2-2' + }} +
+ euro +
+
+
+ {{ + summary.personnelCost | currency : 'EUR' : 'symbol' : '1.2-2' + }} +
+ people +
+
+
+ {{ + summary.consumptionCost | currency : 'EUR' : 'symbol' : '1.2-2' + }} +
+ local_shipping +
+
+
+ {{ + summary.breakEven | currency : 'EUR' : 'symbol' : '1.2-2' + }} +
+ account_balance +
+
+
+ +{{ + summary.forecastRevenue | currency : 'EUR' : 'symbol' : '1.2-2' + }} +
+ account_balance +
+
+
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.spec.ts new file mode 100644 index 0000000..3c77286 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { BreakevenSummaryComponent } from './breakeven-summary.component'; + +describe('BreakevenSummaryComponent', () => { + let component: BreakevenSummaryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BreakevenSummaryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(BreakevenSummaryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.ts new file mode 100644 index 0000000..3e50c23 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/breakeven-summary/breakeven-summary.component.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { BehaviorSubject, map } from 'rxjs'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; +import { summarizeMetrics } from '../../util/summarize-metrics'; + +@Component({ + selector: 'dks-breakeven-summary', + standalone: true, + imports: [CommonModule, MatIconModule], + templateUrl: './breakeven-summary.component.html', + styleUrls: ['./breakeven-summary.component.css'], +}) +export class BreakevenSummaryComponent { + @Input() set metrics(value: TimePerTourMetric[]) { + this._metrics$.next(value); + } + + private _metrics$ = new BehaviorSubject([]); + + metricsSummary$ = this._metrics$.pipe( + map((metrics) => summarizeMetrics(metrics)) + ); +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.html new file mode 100644 index 0000000..f0f3b9d --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.html @@ -0,0 +1,41 @@ +
+ + + +
+ Zeige Kategorien + +
+
+ + + + + + + + +
+
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.spec.ts new file mode 100644 index 0000000..0244dbf --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MetricSummaryComponent } from './metric-summary.component'; + +describe('MetricSummaryComponent', () => { + let component: MetricSummaryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [MetricSummaryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MetricSummaryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.ts new file mode 100644 index 0000000..493ceb8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/metric-summary/metric-summary.component.ts @@ -0,0 +1,59 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { BehaviorSubject, map } from 'rxjs'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; +import { DiagrammType, MetricTimeScale } from '../../types'; +import { AverageRevenueMetricComponent } from '../average-revenue-metric/average-revenue-metric.component'; +import { AverageRevenueSummaryComponent } from '../average-revenue-summary/average-revenue-summary.component'; +import { BreakevenMetricComponent } from '../breakeven-metric/breakeven-metric.component'; +import { BreakevenSummaryComponent } from '../breakeven-summary/breakeven-summary.component'; +import { TimePerTourMetricComponent } from '../time-per-tour-metric/time-per-tour-metric.component'; +import { TimePerTourSummaryComponent } from '../time-per-tour-summary/time-per-tour-summary.component'; + +@Component({ + selector: 'dks-metric-summary', + standalone: true, + imports: [ + CommonModule, + AverageRevenueMetricComponent, + AverageRevenueSummaryComponent, + BreakevenMetricComponent, + BreakevenSummaryComponent, + TimePerTourMetricComponent, + TimePerTourSummaryComponent, + MatSlideToggleModule, + ], + templateUrl: './metric-summary.component.html', + styleUrls: ['./metric-summary.component.css'], +}) +export class MetricSummaryComponent { + @Input() set metrics(metrics: TimePerTourMetric[]) { + this._metrics$.next(metrics); + } + @Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour; + @Input() metricTimeScale!: MetricTimeScale; + @Input() labelFormat!: string; + + DiagrammType = DiagrammType; + _metrics$ = new BehaviorSubject([]); + showCategories = false; + + vm$ = this._metrics$.pipe( + map((metrics) => ({ + metrics, + })) + ); + + getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string { + if (timeScale === MetricTimeScale.Dayview) { + return 'HH:mm'; + } else if (timeScale === MetricTimeScale.Monthview) { + return 'd.M.yy'; + } else { + throw new Error( + 'No label format for time scale defined, TimeScale: ' + timeScale + ); + } + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.html new file mode 100644 index 0000000..fafedf8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.html @@ -0,0 +1,7 @@ +
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.spec.ts new file mode 100644 index 0000000..fb1dd71 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TimePerTourMetricComponent } from './time-per-tour-metric.component'; + +describe('TimePerTourMetricComponent', () => { + let component: TimePerTourMetricComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimePerTourMetricComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TimePerTourMetricComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.ts new file mode 100644 index 0000000..95de87b --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-metric/time-per-tour-metric.component.ts @@ -0,0 +1,184 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, OnChanges } from '@angular/core'; +import { format } from 'date-fns'; +import { BarSeriesOption, EChartsOption, LegendComponentOption } from 'echarts'; +import { BarChart, LineChart } from 'echarts/charts'; +import { + GridComponent, + LegendComponent, + TooltipComponent, +} from 'echarts/components'; +import { BehaviorSubject } from 'rxjs'; +import { UtilNgxEchartsModule } from '../../../../core/components/ngx-echarts/util-ngx-echarts.module'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; +import { MetricTimeScale } from '../../types'; + +@Component({ + selector: 'dks-time-per-tour-metric', + standalone: true, + imports: [CommonModule, UtilNgxEchartsModule], + templateUrl: './time-per-tour-metric.component.html', + styleUrls: ['./time-per-tour-metric.component.css'], +}) +export class TimePerTourMetricComponent implements OnChanges { + @Input() metrics: TimePerTourMetric[] = []; + @Input() labelFormat!: string; + @Input() showCategories!: boolean; + @Input() metricTimeScale!: MetricTimeScale; + + echartsExtensions = [ + BarChart, + LineChart, + TooltipComponent, + GridComponent, + LegendComponent, + ]; + + echartsOptions$: BehaviorSubject = new BehaviorSubject( + this.generateEChartsOptions(this.metrics) + ); + + ngOnChanges(): void { + this.echartsOptions$.next(this.generateEChartsOptions(this.metrics)); + } + + private generateEChartsOptions(metrics: TimePerTourMetric[]): EChartsOption { + const categories = [ + ...new Set( + metrics + .flatMap(({ categoryAmounts }) => categoryAmounts) + .map(({ category }) => + category === 'null' ? 'Ohne Angabe' : category + ) + ), + ]; + const legendData = [ + ...(this.showCategories ? categories : ['Touren']), + 'Kapazität', + ]; + const yAxisName = 'Kapazität pro Tour'; + + const legendOptions: LegendComponentOption = { + orient: 'horizontal', + type: 'scroll', + data: legendData, + }; + + return { + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + }, + grid: { + left: '15%', + width: '80%', + bottom: 50, + }, + legend: legendOptions, + xAxis: [ + { + type: 'category', + axisTick: { + show: true, + alignWithLabel: true, + }, + axisLabel: { + rotate: 30, + interval: 1, + fontSize: 10, + }, + data: metrics.map(({ interval: { start } }) => + format(new Date(start), this.labelFormat) + ), + }, + ], + yAxis: [ + { + type: 'value', + name: yAxisName, + position: 'left', + alignTicks: true, + axisLine: { + show: true, + }, + minInterval: 1, + axisLabel: { + fontSize: 10, + }, + }, + ], + series: [ + { + name: 'Kapazität', + type: 'line', + data: metrics.map(({ availableTime }) => availableTime), + smooth: true, + markPoint: { + symbol: 'diamond', + symbolSize: 10, + data: [ + { + name: 'Min', + coord: ['13.5.23', 90], + }, + ], + }, + }, + ...(this.showCategories + ? categories.map( + (category): BarSeriesOption => ({ + name: category, + type: 'bar', + stack: 'tourAmount', + data: metrics.map( + ({ categoryAmounts }) => + categoryAmounts.find(({ category: c }) => + category === 'Ohne Angabe' ? !c : c === category + )?.amount ?? 0 + ), + barWidth: 8, + }) + ) + : ([ + { + name: 'Touren', + type: 'bar', + stack: 'tourAmount', + data: metrics.map(({ tourAmount, forecast }) => ({ + value: tourAmount, + itemStyle: { + ...(forecast > 0 && + this.metricTimeScale === MetricTimeScale.Monthview + ? { + borderRadius: [0, 0, 0, 0], + } + : {}), + }, + })), + itemStyle: { + borderRadius: [5, 5, 0, 0], + }, + barWidth: 8, + }, + ] as BarSeriesOption[])), + // TODO: Add feature toggle for forecast + // ...(this.metricTimeScale === MetricTimeScale.Monthview + // ? ([ + // { + // name: 'Vorausschau', + // type: 'bar', + // stack: 'tourAmount', + // data: metrics.map(({ forecast }) => forecast.toFixed(2)), + // itemStyle: { + // borderRadius: [5, 5, 0, 0], + // }, + // barWidth: 8, + // }, + // ] as BarSeriesOption[]) + // : []), + ], + }; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.css new file mode 100644 index 0000000..95dedde --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.css @@ -0,0 +1,3 @@ +:host { + @apply flex justify-center items-center; +} \ No newline at end of file diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.html new file mode 100644 index 0000000..13b77f8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.html @@ -0,0 +1,42 @@ +
+
+ {{ summary.tourAmount | number : '1.2-2' }} +
+ local_shipping +
+
+ + +
+ {{ summary.availableTime | number : '1.2-2' }} h +
+ departure_board +
+
+
+ {{ + summary.breakEven | currency : 'EUR' : 'symbol' : '1.2-2' + }} +
+ account_balance +
+
+ + +
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.spec.ts new file mode 100644 index 0000000..82a260d --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TimePerTourSummaryComponent } from './time-per-tour-summary.component'; + +describe('TimePerTourSummaryComponent', () => { + let component: TimePerTourSummaryComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TimePerTourSummaryComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TimePerTourSummaryComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.ts new file mode 100644 index 0000000..de541d8 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/time-per-tour-summary/time-per-tour-summary.component.ts @@ -0,0 +1,25 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { BehaviorSubject, map } from 'rxjs'; +import { summarizeMetrics } from '../../util/summarize-metrics'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-time-per-tour-summary', + standalone: true, + imports: [CommonModule, MatIconModule], + templateUrl: './time-per-tour-summary.component.html', + styleUrls: ['./time-per-tour-summary.component.css'], +}) +export class TimePerTourSummaryComponent { + @Input() set metrics(value: TimePerTourMetric[]) { + this._metrics$.next(value); + } + + private _metrics$ = new BehaviorSubject([]); + + metricsSummary$ = this._metrics$.pipe( + map((metrics) => summarizeMetrics(metrics)) + ); +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.css b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.html b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.html new file mode 100644 index 0000000..e7c8580 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.html @@ -0,0 +1,49 @@ +
+ {{ viewType }} + + +
+
+ {{ summary.tourAmount.toFixed(2) }} + local_shipping +
+
+ {{ summary.availableTime.toFixed(2) }} + departure_board +
+
+
+
+ {{ summary.breakEven | currency:'EUR' }} + account_balance +
+
+ {{ summary.forecastRevenue | currency:'EUR' }} + account_balance +
+
+
+
+ Ø + {{ summary.averageRevenue | currency:'EUR' }} +
+
+ {{ calculateIndex(summary).toFixed(2) }} + Index +
+
+
+
diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.spec.ts new file mode 100644 index 0000000..d5049b5 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ViewTypeSelectorComponent } from './view-type-selector.component'; + +describe('ViewTypeSelectorComponent', () => { + let component: ViewTypeSelectorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewTypeSelectorComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ViewTypeSelectorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.ts b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.ts new file mode 100644 index 0000000..d28dee4 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/components/view-type-selector/view-type-selector.component.ts @@ -0,0 +1,45 @@ +import { + AsyncPipe, + CurrencyPipe, + NgClass, + NgIf, + NgSwitch, + NgSwitchCase, +} from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { BehaviorSubject, map } from 'rxjs'; +import { DashboardViewType, DiagrammType } from '../../types'; +import { summarizeMetrics } from '../../util/summarize-metrics'; +import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated'; + +@Component({ + selector: 'dks-view-type-selector', + standalone: true, + imports: [NgIf, NgSwitch, NgSwitchCase, NgClass, AsyncPipe, CurrencyPipe, MatIconModule], + templateUrl: './view-type-selector.component.html', + styleUrls: ['./view-type-selector.component.css'], +}) +export class ViewTypeSelectorComponent { + @Input() viewType!: DashboardViewType; + @Input() currentMetric!: DiagrammType; + @Input() selected = false; + @Input() set metrics(metrics: TimePerTourMetric[]) { + this.metrics$.next(metrics); + } + @Output() viewTypeSelected = new EventEmitter(); + + private metrics$ = new BehaviorSubject([]); + metricSummary$ = this.metrics$.pipe( + map((metrics) => summarizeMetrics(metrics)) + ); + DiagrammType = DiagrammType; + + setSelectedViewType(): void { + this.viewTypeSelected.emit(); + } + + calculateIndex(metric: TimePerTourMetric): number { + return Math.floor((metric.availableTime / metric.tourAmount) * 100) / 100; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.css b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.html b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.html new file mode 100644 index 0000000..a3bf181 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.html @@ -0,0 +1,140 @@ + +
+ +
+
+ +
+ + {{ vm.selectedDate | date : 'dd.MM.yyyy' }} + + + + +
+ +
+ + + Kapazität pro Tour + + + Deckungsbeitrag + + + Durchschnittsumsatz + + +
+
+ + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + + + {{ typeAndSummary.type }} + diff --git a/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.spec.ts new file mode 100644 index 0000000..fefd34d --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { DayviewComponent } from './dayview.component'; + +describe('DayviewComponent', () => { + let component: DayviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [DayviewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(DayviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.ts b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.ts new file mode 100644 index 0000000..1dd28e5 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/dayview/dayview.component.ts @@ -0,0 +1,131 @@ +import { Component } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { MatChipListboxChange } from '@angular/material/chips'; +import { addDays, addHours, format, isSunday } from 'date-fns'; +import { + BehaviorSubject, + combineLatest, + map, + startWith, + switchMap, +} from 'rxjs'; +import { groupBy } from '../../../core/util/group-by'; +import { ManagerDashboardService } from '../manager-dashboard.service'; +import { + DashboardViewType, + DiagrammType, + MetricTimeScale, + OrdinanceTypes, +} from '../types'; +import { mergeMetrics } from '../util/merge-metric'; + +@Component({ + selector: 'dks-dayview', + templateUrl: './dayview.component.html', + styleUrls: ['./dayview.component.css'], +}) +export class DayviewComponent { + OrdinanceTypes = OrdinanceTypes; + DiagrammType = DiagrammType; + DashboardViewType = DashboardViewType; + minSelectableDate = new Date('2023-01-01'); + metricTimeScale = MetricTimeScale.Dayview; + selectedDate = new FormControl(new Date()); + selectedDate$ = this.selectedDate.valueChanges.pipe( + startWith(this.selectedDate.value), + map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date)) + ); + selectedMetric$ = new BehaviorSubject(DiagrammType.TimePerTour); + selectedViewType$ = new BehaviorSubject( + DashboardViewType.Gesamt + ); + + vm$; + + constructor( + private readonly managerDashboardService: ManagerDashboardService + ) { + this.vm$ = combineLatest([ + this.selectedDate$.pipe( + switchMap((selectedDate) => + this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay( + OrdinanceTypes.BTW, + selectedDate ?? new Date() + ) + ) + ), + this.selectedDate$.pipe( + switchMap((selectedDate) => + this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay( + OrdinanceTypes.TSW, + selectedDate ?? new Date() + ) + ) + ), + this.selectedDate$.pipe( + switchMap((selectedDate) => + this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay( + OrdinanceTypes.KTW, + selectedDate ?? new Date() + ) + ) + ), + this.selectedMetric$, + this.selectedDate$, + this.managerDashboardService.isLoadingDayview$, + this.selectedViewType$, + ]).pipe( + map( + ([ + btwMetrics, + tswMetrics, + ktwMetrics, + selectedMetric, + selectedDate, + isLoading, + selectedViewType, + ]) => ({ + btwMetrics, + tswMetrics, + ktwMetrics, + selectedMetric, + selectedDate, + isLoading, + selectedViewType, + combinedMetrics: mergeMetrics( + Object.values( + groupBy( + [...btwMetrics, ...tswMetrics, ...ktwMetrics], + ({ interval: { start } }) => format(new Date(start), 'HH:mm') + ) + ) + ), + }) + ) + ); + } + + notSunday = (date: Date | null): boolean => { + return !isSunday(date ?? new Date()); + }; + + addDayToSelectedDate(days: number): void { + const nextSelectedDate = addDays( + this.selectedDate.value ?? new Date(), + days + ); + if (isSunday(nextSelectedDate)) { + this.selectedDate.setValue(addDays(nextSelectedDate, days)); + } else { + this.selectedDate.setValue(nextSelectedDate); + } + } + + changeSelectedType(event: MatChipListboxChange) { + this.selectedMetric$.next(event.value); + } + + setSelectedViewType(viewType: DashboardViewType) { + this.selectedViewType$.next(viewType); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard.service.ts b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard.service.ts new file mode 100644 index 0000000..6afb2f6 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard.service.ts @@ -0,0 +1,155 @@ +import { Injectable } from '@angular/core'; +import { HotToastService } from '@ngxpert/hot-toast'; +import { + BehaviorSubject, + Observable, + catchError, + combineLatest, + map, + of, + retry, + shareReplay, + startWith, + switchMap, + tap, + throwError, +} from 'rxjs'; +import { + GetTourAmountPerTimeKpiManagerGQL, + GetTimePerTourMetricForDayGQL, + GetTimePerTourMetricForMonthGQL, + TimePerTourUpdatedGQL, + GetTourAmountPerTimeKpiManagerQuery, + GetTimePerTourMetricForDayQuery, + GetTimePerTourMetricForMonthQuery, +} from '../../core/data-access/graphql/generated/generated'; + +@Injectable({ providedIn: 'root' }) +export class ManagerDashboardService { + isLoadingDayview$ = new BehaviorSubject(false); + isLoadingMonthview$ = new BehaviorSubject(false); + timePerTourUpdate$; + + constructor( + private readonly getTourAmountPerTimeKPIGql: GetTourAmountPerTimeKpiManagerGQL, + private readonly getTimePerTourMetricForDayGql: GetTimePerTourMetricForDayGQL, + private readonly getTimePerTourMetricForMonthGql: GetTimePerTourMetricForMonthGQL, + private readonly timePerTourUpdatedGql: TimePerTourUpdatedGQL, + private readonly toast: HotToastService + ) { + this.timePerTourUpdate$ = this.timePerTourUpdatedGql.subscribe().pipe( + map((res) => res.data?.timePerTourUpdated), + tap((value) => { + if (value) { + this.toast.info('Daten wurden aktualisiert', { + id: 'update', + }); + } + }), + shareReplay(1) + ); + } + + getTimePerTourKPIByOrdinanceTypes( + ordinanceType: string + ): Observable { + const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({ + ordinanceType, + }); + + const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe( + switchMap(() => timePerTourKPIQuery.refetch()), + startWith(undefined) + ); + return combineLatest([ + timePerTourKPIUpdate, + timePerTourKPIQuery.valueChanges, + ]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 1000, count: 3 }), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => response.data.toursPerTimeKPI) + ); + } + + getTimePerTourMetricByOrdinanceTypeForDay( + ordinanceType: string, + day: Date + ): Observable { + const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({ + ordinanceType, + day, + }); + + const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe( + switchMap(() => timePerTourMetricQuery.refetch()), + startWith(undefined) + ); + return combineLatest([ + timePerTourKPIUpdate, + of([]).pipe( + tap(() => this.isLoadingDayview$.next(true)), + switchMap(() => timePerTourMetricQuery.valueChanges) + ), + ]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 1000, count: 3 }), + tap(() => this.isLoadingDayview$.next(false)), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => response.data.timePerTourMetricForDay) + ); + } + + getTimePerTourMetricByOrdinanceTypeForMonth( + ordinanceType: string, + day: Date + ): Observable< + GetTimePerTourMetricForMonthQuery['timePerTourMetricForMonth'] + > { + const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch( + { + ordinanceType, + day, + }, + { errorPolicy: 'all' } + ); + + const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe( + switchMap(() => timePerTourMetricQuery.refetch()), + startWith(undefined) + ); + return combineLatest([ + timePerTourKPIUpdate, + of([]).pipe( + tap(() => this.isLoadingMonthview$.next(true)), + switchMap(() => timePerTourMetricQuery.valueChanges) + ), + ]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 10000, count: 3 }), + tap(() => this.isLoadingMonthview$.next(false)), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => { + if (response.error || response.errors) { + console.error(response.error, response.errors); + } + return response.data.timePerTourMetricForMonth; + }) + ); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.css b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.html b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.html new file mode 100644 index 0000000..487475f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.html @@ -0,0 +1,14 @@ + + + + diff --git a/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.spec.ts new file mode 100644 index 0000000..c9386fb --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ManagerDashboardComponent } from './manager-dashboard.component'; + +describe('ManagerDashboardComponent', () => { + let component: ManagerDashboardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ManagerDashboardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ManagerDashboardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.ts b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.ts new file mode 100644 index 0000000..53f2a1f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/manager-dashboard/manager-dashboard.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'dks-manager-dashboard', + templateUrl: './manager-dashboard.component.html', + styleUrls: ['./manager-dashboard.component.css'], +}) +export class ManagerDashboardComponent { + tabs: { title: string; route: string }[] = [ + { + title: 'Übersicht', + route: '/managers/overview', + }, + { + title: 'Tagesansicht', + route: '/managers/dayview', + }, + { + title: 'Monatsansicht', + route: '/managers/monthview', + }, + ]; +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.css b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.css new file mode 100644 index 0000000..33a01b3 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.css @@ -0,0 +1,7 @@ +.month-picker .mat-calendar-period-button { + pointer-events: none; +} + +.month-picker .mat-calendar-arrow { + display: none; +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.html b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.html new file mode 100644 index 0000000..1c16c56 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.html @@ -0,0 +1,141 @@ + +
+ +
+
+ +
+ + {{ vm.selectedDate | date : 'MM.yyyy' }} + + + + +
+ +
+ + + + Kapazität pro Tour + + + Deckungsbeitrag + + + Durchschnittsumsatz + + +
+
+ + + + +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + + diff --git a/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.spec.ts new file mode 100644 index 0000000..d101cda --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { MonthviewComponent } from './monthview.component'; + +describe('MonthviewComponent', () => { + let component: MonthviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [MonthviewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(MonthviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.ts b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.ts new file mode 100644 index 0000000..8abf724 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/monthview/monthview.component.ts @@ -0,0 +1,139 @@ +import { Component } from '@angular/core'; +import { FormControl } from '@angular/forms'; +import { MatChipListboxChange } from '@angular/material/chips'; +import { MatDatepicker } from '@angular/material/datepicker'; +import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns'; +import { + BehaviorSubject, + combineLatest, + map, + startWith, + switchMap, +} from 'rxjs'; +import { groupBy } from '../../../core/util/group-by'; +import { ManagerDashboardService } from '../manager-dashboard.service'; +import { + DashboardViewType, + DiagrammType, + MetricTimeScale, + OrdinanceTypes, +} from '../types'; +import { mergeMetrics } from '../util/merge-metric'; + +@Component({ + selector: 'dks-monthview', + templateUrl: './monthview.component.html', + styleUrls: ['./monthview.component.css'], +}) +export class MonthviewComponent { + OrdinanceTypes = OrdinanceTypes; + DiagrammType = DiagrammType; + DashboardViewType = DashboardViewType; + minSelectableDate = new Date('2022-08-01'); + metricTimeScale = MetricTimeScale.Monthview; + selectedDate = new FormControl(addDays(startOfMonth(new Date()), 1)); + selectedDate$ = this.selectedDate.valueChanges.pipe( + startWith(this.selectedDate.value) + ); + selectedMetric$ = new BehaviorSubject(DiagrammType.TimePerTour); + selectedViewType$ = new BehaviorSubject( + DashboardViewType.Gesamt + ); + + vm$; + + constructor( + private readonly managerDashboardService: ManagerDashboardService + ) { + this.vm$ = combineLatest([ + this.selectedDate$.pipe( + switchMap((selectedDate) => + this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth( + OrdinanceTypes.BTW, + selectedDate ?? new Date() + ) + ) + ), + this.selectedDate$.pipe( + switchMap((selectedDate) => + this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth( + OrdinanceTypes.TSW, + selectedDate ?? new Date() + ) + ) + ), + this.selectedDate$.pipe( + switchMap((selectedDate) => + this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth( + OrdinanceTypes.KTW, + selectedDate ?? new Date() + ) + ) + ), + this.selectedMetric$, + this.selectedDate$, + this.managerDashboardService.isLoadingMonthview$, + this.selectedViewType$, + ]).pipe( + map( + ([ + btwMetrics, + tswMetrics, + ktwMetrics, + selectedMetric, + selectedDate, + isLoading, + selectedViewType, + ]) => ({ + btwMetrics, + tswMetrics, + ktwMetrics, + selectedMetric, + selectedDate, + isLoading, + selectedViewType, + combinedMetrics: mergeMetrics( + Object.values( + groupBy( + [...btwMetrics, ...tswMetrics, ...ktwMetrics], + ({ interval: { start } }) => format(new Date(start), 'd.M.yy') + ) + ) + ), + }) + ) + ); + } + + notSunday = (date: Date | null): boolean => { + return !isSunday(date ?? new Date()); + }; + + addMonthToSelectedDate(months: number): void { + const nextSelectedDate = addMonths( + this.selectedDate.value ?? new Date(), + months + ); + if (isSunday(nextSelectedDate)) { + this.selectedDate.setValue(addDays(nextSelectedDate, 1)); + } else { + this.selectedDate.setValue(nextSelectedDate); + } + } + + changeSelectedType(event: MatChipListboxChange) { + this.selectedMetric$.next(event.value); + } + + setMonthAndYear( + normalizedMonthAndYear: Date, + datepicker: MatDatepicker + ) { + this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1)); + datepicker.close(); + } + + setSelectedViewType(viewType: DashboardViewType) { + this.selectedViewType$.next(viewType); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.css b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.html b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.html new file mode 100644 index 0000000..430884b --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.html @@ -0,0 +1,67 @@ +
+
+ + + + +
+
+ + + + + + +
+
+ + + diff --git a/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.spec.ts new file mode 100644 index 0000000..b050c05 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { OverviewComponent } from './overview.component'; + +describe('OverviewComponent', () => { + let component: OverviewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [OverviewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.ts b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.ts new file mode 100644 index 0000000..9d6930f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/overview/overview.component.ts @@ -0,0 +1,112 @@ +import { Component } from '@angular/core'; +import { format, Interval } from 'date-fns'; +import { BehaviorSubject, combineLatest, map } from 'rxjs'; +import { ManagerDashboardService } from '../manager-dashboard.service'; +import { OrdinanceTypes } from '../types'; + +@Component({ + selector: 'dks-overview', + templateUrl: './overview.component.html', + styleUrls: ['./overview.component.css'], +}) +export class OverviewComponent { + OrdinanceTypes = OrdinanceTypes; + selectedOrdinanceType$ = new BehaviorSubject( + undefined + ); + vm$; + + constructor( + private readonly managerDashboardService: ManagerDashboardService + ) { + this.vm$ = combineLatest([ + this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes( + OrdinanceTypes.BTW + ), + this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes( + OrdinanceTypes.TSW + ), + this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes( + OrdinanceTypes.KTW + ), + this.selectedOrdinanceType$, + ]).pipe( + map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({ + btwKPI: { + summaries: btwKPIs.map(({ interval, kpi }) => ({ interval, kpi })), + cars: this.createCarTableData( + btwKPIs.map(({ interval, cars }) => ({ interval, cars })) + ), + }, + tswKPI: { + summaries: tswKPIs.map(({ interval, kpi }) => ({ interval, kpi })), + cars: this.createCarTableData( + tswKPIs.map(({ interval, cars }) => ({ interval, cars })) + ), + }, + ktwKPI: { + summaries: ktwKPIs.map(({ interval, kpi }) => ({ interval, kpi })), + cars: this.createCarTableData( + ktwKPIs.map(({ interval, cars }) => ({ interval, cars })) + ), + }, + selectedOrdinanceType, + })) + ); + } + + setSelectedOrdinanceType(ordinanceType: OrdinanceTypes): void { + if (this.selectedOrdinanceType$.getValue() === ordinanceType) { + this.selectedOrdinanceType$.next(undefined); + } else { + this.selectedOrdinanceType$.next(ordinanceType); + } + } + + private createCarTableData( + carsByInterval: { + interval: Interval; + cars: { + carName: string; + kpi: { + value: number; + meta: { availableTime: number; tourAmount: number }; + }; + }[]; + }[] + ) { + const columns = [ + { id: 'carName', title: 'Fahrzeug' }, + ...carsByInterval.map(({ interval }, index) => ({ + id: `slot${index}`, + title: `${format(new Date(interval.start), 'HH:mm')} - ${format( + new Date(interval.end), + 'HH:mm' + )}`, + })), + ]; + const groupedByCars = carsByInterval.reduce( + (grouped, carsWithInterval, index) => { + const { cars } = carsWithInterval; + let newGroup = { ...grouped }; + for (const car of cars) { + newGroup = { + ...newGroup, + [car.carName]: { + carName: car.carName, + ...(newGroup[car.carName] ?? {}), + [`slot${index}`]: car.kpi.value, + additionalData: [ + ...(newGroup[car.carName]?.additionalData ?? []), + car.kpi.meta, + ], + }, + }; + } + return newGroup; + }, + {} as Record + ); + return { columns, data: Object.values(groupedByCars) }; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car-datasource.ts b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car-datasource.ts new file mode 100644 index 0000000..752ac99 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car-datasource.ts @@ -0,0 +1,86 @@ +import { DataSource } from '@angular/cdk/collections'; +import { MatPaginator } from '@angular/material/paginator'; +import { MatSort } from '@angular/material/sort'; +import { map } from 'rxjs/operators'; +import { Observable, of as observableOf, merge } from 'rxjs'; + +export interface TimePerTourByCarItem { + name: string; + id: number; +} + +/** + * Data source for the TimePerTourByCar view. This class should + * encapsulate all logic for fetching and manipulating the displayed data + * (including sorting, pagination, and filtering). + */ +export class TimePerTourByCarDataSource extends DataSource { + data: TimePerTourByCarItem[] = []; + paginator: MatPaginator | undefined; + sort: MatSort | undefined; + + constructor() { + super(); + } + + /** + * Connect this data source to the table. The table will only update when + * the returned stream emits new items. + * @returns A stream of the items to be rendered. + */ + connect(): Observable { + if (this.paginator && this.sort) { + // Combine everything that affects the rendered data into one update + // stream for the data-table to consume. + return merge(observableOf(this.data), this.paginator.page, this.sort.sortChange) + .pipe(map(() => { + return this.getPagedData(this.getSortedData([...this.data ])); + })); + } else { + throw Error('Please set the paginator and sort on the data source before connecting.'); + } + } + + /** + * Called when the table is being destroyed. Use this function, to clean up + * any open connections or free any held resources that were set up during connect. + */ + disconnect(): void {} + + /** + * Paginate the data (client-side). If you're using server-side pagination, + * this would be replaced by requesting the appropriate data from the server. + */ + private getPagedData(data: TimePerTourByCarItem[]): TimePerTourByCarItem[] { + if (this.paginator) { + const startIndex = this.paginator.pageIndex * this.paginator.pageSize; + return data.splice(startIndex, this.paginator.pageSize); + } else { + return data; + } + } + + /** + * Sort the data (client-side). If you're using server-side sorting, + * this would be replaced by requesting the appropriate data from the server. + */ + private getSortedData(data: TimePerTourByCarItem[]): TimePerTourByCarItem[] { + if (!this.sort || !this.sort.active || this.sort.direction === '') { + return data; + } + + return data.sort((a, b) => { + const isAsc = this.sort?.direction === 'asc'; + switch (this.sort?.active) { + case 'name': return compare(a.name, b.name, isAsc); + case 'id': return compare(+a.id, +b.id, isAsc); + default: return 0; + } + }); + } +} + +/** Simple sort comparator for example ID/Name columns (for client-side sorting). */ +function compare(a: string | number, b: string | number, isAsc: boolean): number { + return (a < b ? -1 : 1) * (isAsc ? 1 : -1); +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.css b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.css new file mode 100644 index 0000000..5050fb6 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.css @@ -0,0 +1,3 @@ +.full-width-table { + width: 100%; +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.html b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.html new file mode 100644 index 0000000..a5e9b89 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.html @@ -0,0 +1,46 @@ +
+ + + + + + + + + + + + + +
{{ column.title }}{{ carData[column.id] }}{{(column.id !=='carName' && carData.carName === 'Touren ohne Zuweisung') ? 'T' : ''}} +
+
+
+ {{getSlotTitleByIndex(idx)}} +
+
+ {{detail.availableTime}}departure_board +
+
+ {{detail.tourAmount}}local_shipping +
+
+ {{detail.breakEven}}€account_balance +
+
+
+
+
diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.spec.ts new file mode 100644 index 0000000..ca4d620 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.spec.ts @@ -0,0 +1,34 @@ +import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; + +import { TimePerTourByCarComponent } from './time-per-tour-by-car.component'; + +describe('TimePerTourByCarComponent', () => { + let component: TimePerTourByCarComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ TimePerTourByCarComponent ], + imports: [ + NoopAnimationsModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + ] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TimePerTourByCarComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.ts b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.ts new file mode 100644 index 0000000..8cfa709 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-by-car/time-per-tour-by-car.component.ts @@ -0,0 +1,50 @@ +import { + animate, + state, + style, + transition, + trigger, +} from '@angular/animations'; +import { Component, Input, OnChanges, OnInit } from '@angular/core'; + +@Component({ + selector: 'dks-time-per-tour-by-car', + templateUrl: './time-per-tour-by-car.component.html', + styleUrls: ['./time-per-tour-by-car.component.css'], + animations: [ + trigger('detailExpand', [ + state('collapsed', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition( + 'expanded <=> collapsed', + animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)') + ), + ]), + ], +}) +export class TimePerTourByCarComponent implements OnInit, OnChanges { + @Input() columns: { id: string; title: string }[] = []; + @Input() data: any[] = []; + + /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ + displayedColumns: string[] = []; + expandedCarData = null; + + ngOnInit(): void { + this.displayedColumns = this.columns.map(({ id }) => id); + } + + ngOnChanges(): void { + this.expandedCarData = null; + } + + getSlotTitleByIndex(idx: number): string { + return this.columns.find(({ id }) => id === `slot${idx}`)?.title ?? ''; + } + + setExpandedCarData(carData: any): void { + if(carData.carName !== 'Touren ohne Zuweisung') { + this.expandedCarData = this.expandedCarData === carData ? null : carData; + } + } +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.css b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.css new file mode 100644 index 0000000..e69de29 diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.html b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.html new file mode 100644 index 0000000..767ef24 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.html @@ -0,0 +1,38 @@ + + + {{ text }} + + +
+
+
departure_board
+
local_shipping
+
account_balance
+
+
+
+ {{ kpi.interval.start | date: 'HH:mm' }} - + {{ kpi.interval.end | date: 'HH:mm' }} +
+
+ {{ kpi.kpi.value.toString().replace('.', ',') }} +
+
+ {{ kpi.kpi.meta.availableTime }} +
+
+ {{ kpi.kpi.meta.tourAmount }} +
+
+ {{ kpi.kpi.meta.breakEven}}€ +
+
+
+
+
diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.spec.ts b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.spec.ts new file mode 100644 index 0000000..66c0fe7 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TimePerTourKPIComponent } from './time-per-tour-kpi.component'; + +describe('TimePerTourKPIComponent', () => { + let component: TimePerTourKPIComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TimePerTourKPIComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TimePerTourKPIComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.ts b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.ts new file mode 100644 index 0000000..720a149 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/time-per-tour-kpi/time-per-tour-kpi.component.ts @@ -0,0 +1,35 @@ +import { Component, Input } from '@angular/core'; +import { Interval, isWithinInterval } from 'date-fns'; +import { interval, map, startWith } from 'rxjs'; + +@Component({ + selector: 'dks-time-per-tour-kpi', + templateUrl: './time-per-tour-kpi.component.html', + styleUrls: ['./time-per-tour-kpi.component.css'], +}) +export class TimePerTourKPIComponent { + @Input() text = ''; + @Input() kpis: { + interval: Interval; + kpi: { + value: number; + meta: { + availableTime: number; + tourAmount: number; + breakEven: number; + }; + }; + }[] = []; + + currentTimeSlot$ = interval(60000).pipe( + startWith(0), + map(() => + this.kpis.findIndex((kpi) => + isWithinInterval(new Date(), { + start: new Date(kpi.interval.start), + end: new Date(kpi.interval.end), + }) + ) + ) + ); +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/types.ts b/service/app-hub/dashboard/src/app/pages/manager/types.ts new file mode 100644 index 0000000..70acf0b --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/types.ts @@ -0,0 +1,23 @@ +export enum OrdinanceTypes { + BTW = 'BTW', + TSW = 'TSW', + KTW = 'KTW', +} + +export enum DiagrammType { + TimePerTour = 'TimePerTour', + BreakEven = 'BreakEven', + AverageRevenue = 'AverageRevenue', +} + +export enum DashboardViewType { + BTW = 'BTW', + TSW = 'TSW', + KTW = 'KTW', + Gesamt = 'Gesamt', +} + +export enum MetricTimeScale { + Dayview = 'Dayview', + Monthview = 'Monthview', +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/util/merge-metric.ts b/service/app-hub/dashboard/src/app/pages/manager/util/merge-metric.ts new file mode 100644 index 0000000..aed8aff --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/util/merge-metric.ts @@ -0,0 +1,68 @@ +import { TimePerTourMetric, TimePerTourMetricCategoryAmount } from '../../../core/data-access/graphql/generated/generated'; +import { groupBy } from '../../../core/util/group-by'; + +export function mergeMetrics( + metrics: TimePerTourMetric[][] +): TimePerTourMetric[] { + return metrics.reduce( + (metrics, metricsByLabel) => [ + ...metrics, + { + ...metricsByLabel.reduce( + (metricSum, metric) => ({ + interval: metric.interval, + availableTime: metricSum.availableTime + metric.availableTime, + tourAmount: metricSum.tourAmount + metric.tourAmount, + breakEven: metricSum.breakEven + metric.breakEven, + revenue: metricSum.revenue + metric.revenue, + averageRevenue: metric.averageRevenue, // (metricSum.averageRevenue * index + metric.averageRevenue) / (index +1), + personnelCost: metricSum.personnelCost + metric.personnelCost, + consumptionCost: metricSum.consumptionCost + metric.consumptionCost, + forecast: metricSum.forecast + metric.forecast, + forecastRevenue: metricSum.forecastRevenue + metric.forecastRevenue, + categoryAmounts: metric.categoryAmounts, + }), + { + interval: { + start: new Date(), + end: new Date(), + }, + availableTime: 0, + tourAmount: 0, + breakEven: 0, + revenue: 0, + averageRevenue: 0, + personnelCost: 0, + consumptionCost: 0, + forecast: 0, + forecastRevenue: 0, + categoryAmounts: [], + } as TimePerTourMetric + ), + averageRevenue: + metricsByLabel.reduce( + (sum, { averageRevenue }) => (sum += averageRevenue), + 0 + ) / + metricsByLabel.filter(({ averageRevenue }) => averageRevenue > 0) + .length || 0, + categoryAmounts: Object.entries( + groupBy( + metricsByLabel.flatMap(({ categoryAmounts }) => categoryAmounts), + ({ category }) => category + ) + ).reduce( + (categoryAmounts, [category, amounts]) => [ + ...categoryAmounts, + { + category, + amount: amounts.reduce((sum, { amount }) => (sum += amount), 0), + }, + ], + [] as TimePerTourMetricCategoryAmount[] + ), + }, + ], + [] + ); +} diff --git a/service/app-hub/dashboard/src/app/pages/manager/util/summarize-metrics.ts b/service/app-hub/dashboard/src/app/pages/manager/util/summarize-metrics.ts new file mode 100644 index 0000000..d71cd55 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/manager/util/summarize-metrics.ts @@ -0,0 +1,49 @@ +import { eachHourOfInterval } from 'date-fns'; +import { TimePerTourMetric } from '../../../core/data-access/graphql/generated/generated'; + +export function summarizeMetrics( + metrics: TimePerTourMetric[] +): TimePerTourMetric { + const metricInterval = { + start: new Date(metrics[0].interval.start), + end: new Date(metrics[0].interval.end), + }; + return { + ...metrics.reduce( + (sum, metric) => ({ + ...sum, + interval: metricInterval, + availableTime: sum.availableTime + metric.availableTime, + tourAmount: sum.tourAmount + metric.tourAmount, + breakEven: sum.breakEven + metric.breakEven, + revenue: sum.revenue + metric.revenue, + personnelCost: sum.personnelCost + metric.personnelCost, + consumptionCost: sum.consumptionCost + metric.consumptionCost, + forecast: + eachHourOfInterval(metricInterval).length <= 1 + ? metric.forecast + : Math.round(sum.forecast) + Math.round(metric.forecast), + forecastRevenue: + eachHourOfInterval(metricInterval).length <= 1 + ? metric.forecastRevenue + : sum.forecastRevenue + metric.forecastRevenue, + }), + { + interval: { start: new Date(), end: new Date() }, + availableTime: 0, + tourAmount: 0, + breakEven: 0, + revenue: 0, + averageRevenue: 0, + personnelCost: 0, + consumptionCost: 0, + forecast: 0, + forecastRevenue: 0, + categoryAmounts: [], + } + ), + averageRevenue: + metrics.reduce((sum, { averageRevenue }) => (sum += averageRevenue), 0) / + metrics.filter(({ averageRevenue }) => averageRevenue > 0).length || 0, + }; +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/category-colors.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/category-colors.ts new file mode 100644 index 0000000..e635208 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/category-colors.ts @@ -0,0 +1,70 @@ +export default [ + { + color: '#5c1cf2', + name: '1. Entlassung', + }, + { + color: '#5c1cf2', + name: '2. Entlassung Vorbestellung', + }, + { + color: '#5c1cf2', + name: '3. Einweisung', + }, + { + color: '#ffffff', + name: '4. Arztpraxis', + }, + { + color: '#5c1cf2', + name: '5. Verlegung', + }, + { + color: '#050000', + name: '6. Barzahlung', + }, + { + name: 'Dialyse', + color: '#e33a3a', + }, + { + color: '#c50bf9', + name: 'Feierabend', + }, + { + color: '#e33a3a', + name: 'Konsil KH - Dia', + }, + { + color: '#f4e00b', + name: 'Konsilfahrt', + }, + { + color: '#21e4e8', + name: 'Krankenhaus Ambulant', + }, + { + color: '#1d0c19', + name: 'Privatfahrt Rechnung', + }, + { + color: '#f0d5d5', + name: 'Strahlentherapie', + }, + { + color: '#86f939', + name: 'Tagesklinik - AKW', + }, + { + color: '#c6ec09', + name: 'Tagesklinik - ASK', + }, + { + color: '#008000', + name: 'Tagesklinik - MKH', + }, + { + color: '#f4bb95', + name: 'Transportschein abholen', + }, +]; diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.html b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.html new file mode 100644 index 0000000..eb093c2 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.html @@ -0,0 +1,28 @@ + +
+ + {{ ticket.tours.at(0)?.ordinanceType }} + +
+ + error + +
+
+ {{ ticket.tours.at(0)?.patientName }} + {{ ticket.tours.at(0)?.patientSurname }} + {{ getToursLength() }} Tour{{ + getToursLength() > 1 ? 'en' : '' + }} + {{ getDistance(ticket.createdAt) }} +
diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.spec.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.spec.ts new file mode 100644 index 0000000..2e7e7c6 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TicketItemComponent } from './ticket-item.component'; + +describe('TicketItemComponent', () => { + let component: TicketItemComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TicketItemComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TicketItemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.ts new file mode 100644 index 0000000..de8c18f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-item/ticket-item.component.ts @@ -0,0 +1,72 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + HostBinding, + Input, + OnInit, +} from '@angular/core'; +import { MatBadgeModule } from '@angular/material/badge'; +import { MatIconModule } from '@angular/material/icon'; +import { differenceInBusinessDays, formatDistanceToNow } from 'date-fns'; +import { de } from 'date-fns/locale'; +import { + ApprovalState, + Ticket, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; +import CategoryColors from './category-colors'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-item', + templateUrl: './ticket-item.component.html', + styles: [ + ` + :host { + @apply flex flex-col rounded border-l-2 border-r-8 p-2 m-2 bg-white cursor-pointer; + } + `, + ], + imports: [MatIconModule, MatBadgeModule, NgIf], +}) +export class TicketItemComponent implements OnInit { + @Input() ticket!: Ticket & { tours: Tour[] }; + + @HostBinding('style.border-left-color') + leftBorderColor = '#ffffff'; + @HostBinding('style.border-right-color') + rightBorderColor = '#ffffff'; + + @HostBinding('style.background-color') + backgroundColor = '#ffffff'; + + ApprovalState = ApprovalState; + + ngOnInit(): void { + this.leftBorderColor = this.getColorForCategory( + this.ticket.tours.at(0)?.category + ); + + if ( + differenceInBusinessDays(new Date(this.ticket.urgency), new Date()) <= 2 + ) { + this.rightBorderColor = '#f44336'; + } + } + + getToursLength() { + return this.ticket.tours.filter(({ check }) => check === 0).length; + } + + getDistance(date: Date): string { + return formatDistanceToNow(new Date(date), { locale: de }); + } + + // Static implementation for now. Colors could be loaded from DispoLive in the future, if necessary + getColorForCategory(category: string | null | undefined): string { + const categoryColor = CategoryColors.find(({ name }) => name === category); + return categoryColor?.color ?? '#ffffff'; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-footer.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-footer.component.ts new file mode 100644 index 0000000..d31ef8b --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-footer.component.ts @@ -0,0 +1,41 @@ +import { AsyncPipe, CurrencyPipe, NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { TicketStateMeta } from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-lane-footer', + template: ` + {{ ticketStateMeta.count }} Tickets + + + {{ ticketStateMeta.approvalRequiredCount }} davon genehmigungspflichtig + + + {{ ticketStateMeta.approvalUnknownCount }} davon auf Genehmigungspflicht + zu prüfen + + + {{ ticketStateMeta.approvalNotCompanyCount }} davon fehlende Genehmigung + + + {{ ticketStateMeta.tdNotCompanyCount }} davon fehlender Transportschein + `, + styles: [ + ` + :host { + @apply flex flex-col items-center justify-center p-2; + } + `, + ], + imports: [NgIf, AsyncPipe, CurrencyPipe], +}) +export class TicketLaneFooterComponent { + @Input() ticketStateMeta!: TicketStateMeta; + @Input() withTicketCount = true; + @Input() withApprovalUnknownCount = false; + @Input() withApprovalRequiredCount = false; + @Input() withTdNotCompanyCount = false; + @Input() withApprovalNotCompanyCount = false; +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-header-filter.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-header-filter.component.ts new file mode 100644 index 0000000..03607ad --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-header-filter.component.ts @@ -0,0 +1,214 @@ +import { NgFor, NgSwitch, NgSwitchCase } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewChild, +} from '@angular/core'; +import { FormsModule, NgForm } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { debounceTime, filter, map, tap } from 'rxjs'; +import { + ApprovalState, + TicketFilterArgs, + TicketValidationState, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-lane-header-filter', + template: ` + + + + + + +
+ +
+ {{ + item.text + }} +
+
+ +
+ {{ + item.text + }} +
+
+ +
+ Alle +
+
+ +
+ {{ + item.text + }} +
+
+
+ `, + styles: [], + imports: [ + NgSwitch, + NgSwitchCase, + NgFor, + FormsModule, + MatButtonModule, + MatIconModule, + MatMenuModule, + MatCheckboxModule, + ], +}) +export class TicketLaneHeaderFilterComponent implements AfterViewInit { + @Input() state!: TicketValidationState; + @Output() filterChange = new EventEmitter(); + @ViewChild('form') form!: NgForm; + + TicketValidationState = TicketValidationState; + + ngAfterViewInit() { + this.form.valueChanges + ?.pipe( + // debounce to avoid multiple calls during form initialisation + debounceTime(10), + map((form): TicketFilterArgs => { + const filterKey = this.getFilterKeyForState(); + if (!filterKey) return {}; + + return { + [filterKey]: this.getFilterForState() + .filter(({ value }) => form[value]) + .map(({ value }) => value), + }; + }), + filter((obj) => Object.keys(obj).length > 0), + tap((form) => this.filterChange.emit(form)) + ) + .subscribe(); + } + + private getFilterForState() { + switch (this.state) { + case TicketValidationState.Created: + return this.filter.errors; + case TicketValidationState.UserInteractionRequired: + return this.filter.approvalStates; + case TicketValidationState.DocumentsMissing: + return this.filter.documentInfo; + case TicketValidationState.TdUncertain: + case TicketValidationState.Archived: + return []; + } + } + + private getFilterKeyForState() { + switch (this.state) { + case TicketValidationState.Created: + return 'errors'; + case TicketValidationState.UserInteractionRequired: + return 'approvalStates'; + case TicketValidationState.DocumentsMissing: + return 'documentInfo'; + case TicketValidationState.TdUncertain: + case TicketValidationState.Archived: + return ''; + } + } + + filter = { + approvalStates: [ + { + checked: false, + value: ApprovalState.Free, + text: 'Genehmigungsfrei', + }, + { + checked: false, + value: ApprovalState.Required, + text: 'Genehmigungspflichtig', + }, + { + checked: false, + value: ApprovalState.Unknown, + text: 'Genehmigung unklar', + }, + ], + errors: [ + { + checked: false, + value: 'Patient: Krankenkasse ist nicht gefüllt', + text: 'Patient: Krankenkasse ist nicht gefüllt', + }, + ], + documentInfo: [ + // { + // checked: false, + // value: 'approval', + // text: 'Genehmigung fehlt', + // }, + { + checked: false, + value: 'td_company', + text: 'Transportschein im Betrieb', + }, + { + checked: false, + value: 'td_customer', + text: 'Transportschein beim Kunden', + }, + ], + }; +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-header.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-header.component.ts new file mode 100644 index 0000000..44c7adc --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane-header.component.ts @@ -0,0 +1,55 @@ +import { NgSwitch, NgSwitchCase } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; +import { + TicketFilterArgs, + TicketValidationState, +} from '../../../core/data-access/graphql/generated/generated'; +import { TicketLaneHeaderFilterComponent } from './ticket-lane-header-filter.component'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-lane-header', + template: `
+ +
Touren angenommen
+
+ Genehmigung/Transportschein +
+
+ Transportschein unklar +
+
+ Dokumente fehlen +
+
Archiv
+
+ `, + styles: [ + ` + :host { + @apply flex items-center justify-between p-2 bg-gray-50; + } + `, + ], + imports: [NgSwitch, NgSwitchCase, TicketLaneHeaderFilterComponent], +}) +export class TicketLaneHeaderComponent { + @Input() state!: TicketValidationState; + @Output() filterChange = new EventEmitter(); + + TicketValidationState = TicketValidationState; + + filterChanged(filter: TicketFilterArgs) { + this.filterChange.emit(filter); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane.component.html b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane.component.html new file mode 100644 index 0000000..ca0f729 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane.component.html @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane.component.ts new file mode 100644 index 0000000..62a0486 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-lane/ticket-lane.component.ts @@ -0,0 +1,203 @@ +import { + CdkVirtualScrollViewport, + ScrollingModule, +} from '@angular/cdk/scrolling'; +import { AsyncPipe, NgFor, NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + ViewChild, + inject, +} from '@angular/core'; +import { DialogService } from '@ngneat/dialog'; +import { + BehaviorSubject, + Observable, + combineLatest, + debounceTime, + filter, + map, + mergeMap, + scan, + switchMap, + tap, +} from 'rxjs'; +import { RoleDirective } from '../../../core/components/ms-authentication/role.directive'; +import { + Ticket, + TicketFilterArgs, + TicketValidationState, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; +import { TicketItemComponent } from '../ticket-item/ticket-item.component'; +import { TicketViewComponent } from '../ticket-view/ticket-view.component'; +import { TicketsService } from '../tickets.service'; +import { TicketLaneFooterComponent } from './ticket-lane-footer.component'; +import { TicketLaneHeaderComponent } from './ticket-lane-header.component'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-lane', + templateUrl: './ticket-lane.component.html', + styles: [ + ` + :host { + @apply rounded bg-gray-200 h-full w-full grid; + grid-template-rows: auto 1fr auto; + } + `, + ], + imports: [ + TicketLaneHeaderComponent, + TicketLaneFooterComponent, + TicketItemComponent, + TicketViewComponent, + NgIf, + NgFor, + AsyncPipe, + RoleDirective, + ScrollingModule, + ], +}) +export class TicketLaneComponent { + private dialog = inject(DialogService); + private ticketsService = inject(TicketsService); + + private ticketState$ = new BehaviorSubject( + undefined + ); + private ticketFilter$ = new BehaviorSubject({}); + + @Input() set ticketState(ticketState: TicketValidationState) { + this.ticketState$.next(ticketState); + } + get ticketState() { + return this.ticketState$.getValue() as TicketValidationState; + } + + @ViewChild(CdkVirtualScrollViewport) + viewport!: CdkVirtualScrollViewport; + + TicketState = TicketValidationState; + + take = 20; + hasNextPage = true; + nextPage = ''; + + cursor$ = new BehaviorSubject(''); + tickets$: Observable<(Ticket & { tours: Tour[] })[]>; + ticketStateMeta$ = this.ticketState$.pipe( + filter(Boolean), + switchMap((ticketState) => + this.ticketsService.fetchTicketStateMeta(ticketState) + ) + ); + totalCount$ = new BehaviorSubject(0); + + constructor() { + const batchMap: Observable> = + combineLatest([this.cursor$, this.ticketFilter$]).pipe( + debounceTime(200), + mergeMap(([cursor, filter]) => + this.fetchTickets(cursor, filter).pipe( + map((tickets) => ({ tickets, cursor })) + ) + ), + scan((acc, { tickets, cursor }) => { + const normalizedCursor = this.getNormalizedCursor(cursor); + const existingTickets = Object.entries(acc) + .filter( + ([id]) => + id.split('_').at(0) !== normalizedCursor || + Object.keys(tickets).includes(id.split('_').at(1) ?? '') + ) + .reduce( + (acc, [id, ticket]) => ({ + ...acc, + [`${normalizedCursor}_${id}`]: ticket, + }), + {} as Record + ); + + return { ...existingTickets, ...tickets }; + }, {} as Record) + ); + this.tickets$ = batchMap.pipe( + map((ticketNodes) => + Object.values(ticketNodes).sort( + ({ createdAt: aCreated }, { createdAt: bCreated }) => + new Date(bCreated).getTime() - new Date(aCreated).getTime() + ) + ) + ); + } + + openTicketView(ticket: Ticket & { tours: Tour[] }): void { + const dialogRef = this.dialog.open(TicketViewComponent, { + data: ticket, + width: '40vw', + minHeight: '400px', + }); + + dialogRef.afterClosed$ + .pipe(tap((result) => result && this.openTicketView(result))) + .subscribe(); + } + + trackByFn(index: number, ticket: Ticket): string { + return ticket.id; + } + + fetchNextPage() { + if (!this.hasNextPage) { + return; + } + const start = this.viewport.getRenderedRange().start; + const end = this.viewport.getRenderedRange().end; + const total = this.viewport.getDataLength(); + + if (this.cursor$.getValue() !== '' && start === 0) { + this.cursor$.next(''); + } else if (total - end < 5) { + this.cursor$.next(this.nextPage); + } + } + + private fetchTickets( + cursor: string, + filter: TicketFilterArgs + ): Observable> { + const normalizedCursor = this.getNormalizedCursor(cursor); + + return this.ticketsService + .fetchTicketsByState(this.ticketState, cursor, filter, this.take) + .pipe( + tap(({ endCursor, hasNextPage }) => { + this.nextPage = endCursor; + this.hasNextPage = hasNextPage; + }), + map(({ nodes }) => + nodes?.reduce( + (acc, cur) => ({ + ...acc, + [`${normalizedCursor}_${cur.id}`]: cur as Ticket & { + tours: Tour[]; + }, + }), + {} as Record + ) + ) + ); + } + + private getNormalizedCursor(cursor: string) { + return cursor === '' ? 'MA==' : cursor; + } + + filterChanged(filter: TicketFilterArgs) { + this.ticketFilter$.next(filter); + this.cursor$.next(''); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-system.module.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-system.module.ts new file mode 100644 index 0000000..62df595 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-system.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { TicketsPageComponent } from './tickets-page.component'; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: TicketsPageComponent, + }, + ]), + TicketsPageComponent, + ], + providers: [], + declarations: [], +}) +export class TicketSystemModule {} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/approval-request-dialog.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/approval-request-dialog.component.ts new file mode 100644 index 0000000..1a2a324 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/approval-request-dialog.component.ts @@ -0,0 +1,62 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { DialogCloseDirective, DialogRef } from '@ngneat/dialog'; +import { MatRadioModule } from '@angular/material/radio'; + +@Component({ + template: `
+
Genehmigung angefragt
+
+
+
+ + + Avicenna + Kunde + +
+ + + Name des Sachbearbeiters? + + + +
+ + +
+
+
`, + standalone: true, + imports: [ + DialogCloseDirective, + MatFormFieldModule, + MatButtonModule, + MatRadioModule, + MatInputModule, + MatSelectModule, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApprovalRequestDialogComponent { + ref: DialogRef = inject(DialogRef); + + formModel = { + who: undefined, + staff: undefined, + }; +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/approval-state-decision.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/approval-state-decision.component.ts new file mode 100644 index 0000000..0012d06 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/approval-state-decision.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatSelectModule } from '@angular/material/select'; +import { DialogCloseDirective, DialogRef } from '@ngneat/dialog'; +import { MatRadioModule } from '@angular/material/radio'; + +@Component({ + template: `
+
Genehmigung benötigt?
+

Prüfen Sie ob bei dem Patienten eine Genehmigung benötigt wird

+
+
+
+ + + Nein + Ja + +
+
+ + +
+
+
`, + standalone: true, + imports: [ + DialogCloseDirective, + MatFormFieldModule, + MatButtonModule, + MatRadioModule, + MatInputModule, + MatSelectModule, + FormsModule, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApprovalStateDecisionComponent { + ref: DialogRef = inject(DialogRef); + + approvalNeeded = undefined; +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-documents-missing.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-documents-missing.component.ts new file mode 100644 index 0000000..69f52fa --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-documents-missing.component.ts @@ -0,0 +1,98 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { + ApprovalState, + Ticket, + TicketValidationState, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-documents-missing', + template: `
+
+
+ + +
+
+ + Genehmigung + Transportschein +
+
+
`, + imports: [ + NgIf, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatRadioModule, + MatCheckboxModule, + FormsModule, + ], +}) +export class TicketDocumentsMissingComponent { + @Input() ticket!: Ticket & { tours: Tour[] }; + @Output() saveFormData = new EventEmitter(); + + TicketState = TicketValidationState; + ApprovalState = ApprovalState; + + formModel = { + approval: { + location: false, + }, + tdLocation: false, + }; + + ngOnInit(): void { + this.formModel = { + approval: { + location: this.ticket.documentInfo?.approval?.location === 'company', + }, + tdLocation: this.ticket.documentInfo?.tdLocation === 'company', + }; + } + + saveForm() { + this.saveFormData.emit({ + ...(this.formModel.approval.location + ? { approval: { location: 'company' } } + : {}), + ...(this.formModel.tdLocation ? { tdLocation: 'company' } : {}), + }); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-overview.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-overview.component.ts new file mode 100644 index 0000000..3c2f9e1 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-overview.component.ts @@ -0,0 +1,101 @@ +import { + NgClass, + NgForOf, + NgIf, + NgSwitch, + NgSwitchCase, +} from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { + ApprovalState, + Ticket, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-overview', + template: `
+ +
Ticket Informationen
+ + Typ: + + {{ ticket.tours[0].type }} + + + + Kategorie: + + {{ ticket.tours[0].category }} + + +
+ +
Gehnemigungsinformationen
+ + + Genehmigungsfrei + Automatische Prüfung nicht möglich + Genehmigung benötigt + Genehmigung angefragt + Genehmigung eingegangen + + +
+ +
Ticket Anmerkungen
+ + error + {{ error.split(':').at(0) }} + {{ + error.split(':').at(1) + }} + + + warning + {{ note.split(':').at(0) }} + {{ note.split(':').at(1) }} + +
+
`, + imports: [ + MatListModule, + MatIconModule, + NgClass, + NgIf, + NgForOf, + NgSwitch, + NgSwitchCase, + ], +}) +export class TicketOverviewComponent { + @Input() ticket!: Ticket & { tours: Tour[] }; + + ApprovalState = ApprovalState; +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-td-uncertain.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-td-uncertain.component.ts new file mode 100644 index 0000000..07f0242 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-td-uncertain.component.ts @@ -0,0 +1,78 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatRadioModule } from '@angular/material/radio'; +import { + ApprovalState, + Ticket, + TicketValidationState, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-td-uncertain', + template: `
+
+
+ + +
+
+ + + Im Betrieb + + Beim Kunden + + +
+
+
`, + imports: [ + NgIf, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatRadioModule, + FormsModule, + ], +}) +export class TicketTdUncertainComponent { + @Input() ticket!: Ticket & { tours: Tour[] }; + @Output() saveFormData = new EventEmitter(); + + TicketState = TicketValidationState; + ApprovalState = ApprovalState; + + formModel = { + tdLocation: '', + }; + + saveForm() { + this.saveFormData.emit(this.formModel); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-tours.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-tours.component.ts new file mode 100644 index 0000000..b7ab50c --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-tours.component.ts @@ -0,0 +1,147 @@ +import { SelectionModel } from '@angular/cdk/collections'; +import { DatePipe, NgClass, NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + Input, + OnInit +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatTableModule } from '@angular/material/table'; +import { RoleDirective } from '../../../core/components/ms-authentication/role.directive'; +import { + Ticket, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-tours', + template: `
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Einsatznummer{{ tour.operationId }}Anfahrt + {{ tour.startDate | date : 'HH:mm' }} Uhr
+ {{ tour.startDate | date : 'dd.MM.yy' }} +
Von + {{ tour.startStreet }}
{{ tour.startZip }} {{ tour.startCity }} +
Nach + {{ tour.targetStreet }}
{{ tour.targetZip }} + {{ tour.targetCity }} +
+
`, + imports: [ + NgIf, + NgClass, + DatePipe, + RoleDirective, + MatButtonModule, + MatCheckboxModule, + MatTableModule, + ], +}) +export class TicketToursComponent implements OnInit { + @Input() ticket!: Ticket & { tours: Tour[] }; + + dataSource!: Tour[]; + + columns = [ + 'select', + 'operationId', + 'startDate', + 'startAddress', + 'targetAddress', + ]; + + initialSelection = []; + allowMultiSelect = true; + selection = new SelectionModel( + this.allowMultiSelect, + this.initialSelection + ); + + ngOnInit(): void { + this.dataSource = [...this.ticket.tours] + .filter(({ check }) => check === 0) + .sort( + ({ startDate: aTourDate }, { startDate: bTourDate }) => + new Date(aTourDate).getTime() - new Date(bTourDate).getTime() + ); + } + + /** Whether the number of selected elements matches the total number of rows. */ + isAllSelected() { + const numSelected = this.selection.selected.length; + const numRows = this.dataSource.length; + return numSelected == numRows; + } + + /** Selects all rows if they are not all selected; otherwise clear selection. */ + toggleAllRows() { + this.isAllSelected() + ? this.selection.clear() + : this.dataSource.forEach((row) => this.selection.select(row)); + } + + getTourStateTooltip(tour: Tour): string { + if (tour.check > 1) { + return 'Abgerechnet'; + } + if (tour.check === 1) { + return 'Für Abrechnung vorbereitet'; + } + if (tour.empty) { + return 'Gefahren'; + } + return ''; + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-user-action-required.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-user-action-required.component.ts new file mode 100644 index 0000000..e915815 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-user-action-required.component.ts @@ -0,0 +1,153 @@ +import { NgIf } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { + ApprovalState, + DocumentInfo, + Ticket, + TicketValidationState, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-user-action-required', + template: `
+
+
+ + +
+
+ + + Nein + Ja + +
+ +
+ + + Avicenna + Kunde + +
+ + + Name des Sachbearbeiters? + + +
+ +
+ + + Im Betrieb + + Beim Kunden + + Unklar + +
+
+
`, + imports: [ + NgIf, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatRadioModule, + MatInputModule, + FormsModule, + ], +}) +export class TicketUserActionRequiredComponent implements OnInit { + @Input() ticket!: Ticket & { tours: Tour[] }; + @Output() saveFormData = new EventEmitter(); + + TicketState = TicketValidationState; + ApprovalState = ApprovalState; + + formModel: DocumentInfo = { + approval: { + isNeeded: undefined, + whoRequested: '', + answeredFrom: '', + }, + tdLocation: '', + }; + + ngOnInit(): void { + this.formModel = { + approval: { + isNeeded: this.isApprovalNeeded(), + whoRequested: this.ticket.documentInfo?.approval?.whoRequested || '', + answeredFrom: this.ticket.documentInfo?.approval?.answeredFrom || '', + }, + tdLocation: this.ticket.documentInfo?.tdLocation || '', + }; + } + + saveForm() { + this.saveFormData.emit(this.formModel); + } + + private isApprovalNeeded() { + if (this.ticket.approvalState === ApprovalState.Required) { + return true; + } else if (this.ticket.approvalState === ApprovalState.Free) { + return false; + } else { + return undefined; + } + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.html b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.html new file mode 100644 index 0000000..60ce58f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.html @@ -0,0 +1,38 @@ +

+ {{ ref.data.tours[0].ordinanceType }} - {{ ref.data.tours[0].patientName }} + {{ ref.data.tours[0].patientSurname }} +

+
+ + + + + + + Aktionen + + + + + + + + + + + + diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.spec.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.spec.ts new file mode 100644 index 0000000..e030c65 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { TicketViewComponent } from './ticket-view.component'; + +describe('TicketViewComponent', () => { + let component: TicketViewComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TicketViewComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(TicketViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.ts new file mode 100644 index 0000000..1683085 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/ticket-view/ticket-view.component.ts @@ -0,0 +1,67 @@ +import { NgIf, NgSwitch, NgSwitchCase } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTabsModule } from '@angular/material/tabs'; +import { DialogRef } from '@ngneat/dialog'; +import { HotToastService } from '@ngxpert/hot-toast'; +import { tap } from 'rxjs'; +import { + ApprovalState, + SetDocumentInfoMutationVariables, + Ticket, + TicketValidationState, +} from '../../../core/data-access/graphql/generated/generated'; +import { TicketsService } from '../tickets.service'; +import { TicketDocumentsMissingComponent } from './ticket-documents-missing.component'; +import { TicketOverviewComponent } from './ticket-overview.component'; +import { TicketTdUncertainComponent } from './ticket-td-uncertain.component'; +import { TicketToursComponent } from './ticket-tours.component'; +import { TicketUserActionRequiredComponent } from './ticket-user-action-required.component'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-ticket-view', + templateUrl: './ticket-view.component.html', + imports: [ + MatTabsModule, + MatIconModule, + TicketOverviewComponent, + TicketToursComponent, + TicketUserActionRequiredComponent, + TicketTdUncertainComponent, + TicketDocumentsMissingComponent, + NgIf, + NgSwitch, + NgSwitchCase, + ], +}) +export class TicketViewComponent { + private ticketsService = inject(TicketsService); + private toast = inject(HotToastService); + + ref: DialogRef = inject(DialogRef); + + TicketState = TicketValidationState; + ApprovalState = ApprovalState; + + setDocumentInfo({ + approval, + tdLocation, + }: Omit): void { + this.ticketsService + .setDocumentInfo(this.ref.data.id, { + ...(this.ref.data?.documentInfo ?? {}), + tdLocation, + approval: { + ...(this.ref.data?.documentInfo?.approval ?? {}), + ...approval, + }, + }) + .pipe( + tap(() => this.toast.success(`Ticket aktualisiert`)), + tap(() => this.ref.close()) + ) + .subscribe(); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/tickets-page.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/tickets-page.component.ts new file mode 100644 index 0000000..a163643 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/tickets-page.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { DialogService } from '@ngneat/dialog'; +import { RoleDirective } from '../../core/components/ms-authentication/role.directive'; +import { TicketValidationState } from '../../core/data-access/graphql/generated/generated'; +import { TicketLaneComponent } from './ticket-lane/ticket-lane.component'; +import { TicketsSearchDialogComponent } from './tickets-search-dialog/tickets-search-dialog.component'; + +@Component({ + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'dks-tickets-page', + template: `
+
+ +
+ + + + + +
`, + styles: [ + ` + :host { + display: block; + height: calc(100% - 64px); + } + section { + grid-template-rows: 2rem 1fr; + } + `, + ], + imports: [ + TicketLaneComponent, + TicketsSearchDialogComponent, + MatButtonModule, + MatIconModule, + RoleDirective, + ], +}) +export class TicketsPageComponent { + private dialog = inject(DialogService); + TicketState = TicketValidationState; + + showSearchDialog(): void { + this.dialog.open(TicketsSearchDialogComponent); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/tickets-search-dialog/tickets-search-dialog.component.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/tickets-search-dialog/tickets-search-dialog.component.ts new file mode 100644 index 0000000..868478f --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/tickets-search-dialog/tickets-search-dialog.component.ts @@ -0,0 +1,94 @@ +import { AsyncPipe, NgFor, NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { DialogService } from '@ngneat/dialog'; +import { + debounceTime, + distinctUntilChanged, + filter, + switchMap, + tap, +} from 'rxjs'; +import { + Ticket, + Tour, +} from '../../../core/data-access/graphql/generated/generated'; +import { TicketViewComponent } from '../ticket-view/ticket-view.component'; +import { TicketsService } from '../tickets.service'; + +@Component({ + standalone: true, + template: `
+
Ticket suchen
+
+ + Einsatznummer oder Patient + + +
+
+
+
+ {{ ticket.tours.at(0)?.ordinanceType }} + + ({{ ticket.tours.at(0)?.category }}) + +
+
+ + {{ ticket.tours.at(0)?.patientName }} + {{ ticket.tours.at(0)?.patientSurname }} + + + {{ ticket.tours.length }} Tour{{ + ticket.tours.length > 1 ? 'en' : '' + }} + +
+
+
+
`, + imports: [ + TicketViewComponent, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + AsyncPipe, + NgFor, + NgIf, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TicketsSearchDialogComponent { + private ticketsService = inject(TicketsService); + private dialog = inject(DialogService); + + searchCtrl = new FormControl(''); + tickets$ = this.searchCtrl.valueChanges.pipe( + debounceTime(200), + distinctUntilChanged(), + filter((val) => !!val && val?.length > 2), + switchMap((query) => this.ticketsService.searchTickets(query as string)) + ); + + showTicketView(ticket: unknown): void { + const dialogRef = this.dialog.open(TicketViewComponent, { + data: ticket as Ticket & { tours: Tour[] }, + width: '40vw', + minHeight: '400px', + }); + + dialogRef.afterClosed$ + .pipe(tap((result) => result && this.showTicketView(result))) + .subscribe(); + } +} diff --git a/service/app-hub/dashboard/src/app/pages/ticket-system/tickets.service.ts b/service/app-hub/dashboard/src/app/pages/ticket-system/tickets.service.ts new file mode 100644 index 0000000..6527066 --- /dev/null +++ b/service/app-hub/dashboard/src/app/pages/ticket-system/tickets.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@angular/core'; +import { HotToastService } from '@ngxpert/hot-toast'; +import { + catchError, + combineLatest, + map, + Observable, + retry, + shareReplay, + startWith, + Subject, + switchMap, + tap, + throwError, + timer, +} from 'rxjs'; +import { + GetTicketsGQL, + GetTicketsQuery, + GetTicketStateMetaGQL, + GetTicketStateMetaQuery, + SearchTicketsGQL, + SearchTicketsQuery, + SetDocumentInfoGQL, + SetDocumentInfoMutationVariables, + TicketFilterArgs, + TicketValidationState, +} from '../../core/data-access/graphql/generated/generated'; + +@Injectable({ + providedIn: 'root', +}) +export class TicketsService { + ticketsUpdated$ = timer(60 * 1000).pipe( + tap((value) => { + if (value) { + this.toast.info('Daten wurden aktualisiert', { + id: 'update', + }); + } + }), + shareReplay(1), + startWith(undefined) + ); + + updateTickets$ = new Subject(); + + constructor( + private readonly getTicketsGql: GetTicketsGQL, + private readonly getTicketStateMetaGql: GetTicketStateMetaGQL, + private readonly searchTicketsGql: SearchTicketsGQL, + private readonly setDocumentInfoGql: SetDocumentInfoGQL, + private readonly toast: HotToastService + ) { + this.updateTickets$.next(); + } + + fetchTicketsByState( + state: TicketValidationState, + cursor: string, + filters: TicketFilterArgs, + take = 20 + ): Observable { + const ticketsQuery = this.getTicketsGql.watch({ + state, + cursor, + filters, + take, + }); + + const ticketsUpdate = combineLatest([ + this.ticketsUpdated$, + this.updateTickets$, + ]).pipe( + switchMap(() => ticketsQuery.refetch()), + startWith(undefined) + ); + + return combineLatest([ticketsUpdate, ticketsQuery.valueChanges]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 1000, count: 3 }), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => response.data.tickets) + ); + } + + fetchTicketStateMeta( + state: TicketValidationState + ): Observable { + const ticketStateMetaQuery = this.getTicketStateMetaGql.watch({ + state, + }); + + const ticketsUpdate = combineLatest([ + this.ticketsUpdated$, + this.updateTickets$, + ]).pipe( + switchMap(() => ticketStateMetaQuery.refetch()), + startWith(undefined) + ); + + return combineLatest([ + ticketsUpdate, + ticketStateMetaQuery.valueChanges, + ]).pipe( + catchError((error) => { + console.error(error); + return throwError(() => error); + }), + retry({ delay: 1000, count: 3 }), + map(([updatedData, firstData]) => + updatedData ? updatedData : firstData + ), + map((response) => response.data.ticketStateMeta) + ); + } + + searchTickets( + query: string + ): Observable { + return this.searchTicketsGql + .fetch({ query }) + .pipe(map((response) => response.data.searchTickets)); + } + + setDocumentInfo( + ticketId: string, + { approval, tdLocation }: Omit + ) { + if ((approval as any)?.['__typename']) + delete (approval as any)['__typename']; + + return this.setDocumentInfoGql + .mutate({ + ticketId, + approval, + tdLocation, + }) + .pipe( + map((res) => res.data?.setDocumentInfo), + tap(() => this.updateTickets$.next()) + ); + } +} diff --git a/service/app-hub/dashboard/src/environments/environment.development.ts b/service/app-hub/dashboard/src/environments/environment.development.ts new file mode 100644 index 0000000..ab21a3a --- /dev/null +++ b/service/app-hub/dashboard/src/environments/environment.development.ts @@ -0,0 +1,19 @@ +export const environment = { + production: false, + graphQl: { + endpoint: 'https://avicenna.genius.ceo/graphql', + ws: 'ws://avicenna.genius.ceo/graphql', + }, + msAuth: { + clientId: '9a4a1e7a-9544-439f-9cd3-3c20066a8a82', + authority: + 'https://login.microsoftonline.com/e2851277-bafe-4a67-b98f-0da5d80cf3e1', + redirectUri: 'http://localhost:4200/', + postLogoutRedirectUri: 'http://localhost:4200/', + }, + telemetry: { + collectorSecret: 't3stK3y', + otlpEndpoint: 'https://alloy.genius.ceo/collect', + serviceName: 'avicenna.dashboard.dev' + } +}; diff --git a/service/app-hub/dashboard/src/environments/environment.prod.ts b/service/app-hub/dashboard/src/environments/environment.prod.ts new file mode 100644 index 0000000..c27c9ef --- /dev/null +++ b/service/app-hub/dashboard/src/environments/environment.prod.ts @@ -0,0 +1,18 @@ +export const environment = { + production: true, + graphQl: { + endpoint: '{{GRAPHQL_ENDPOINT}}', + ws: '{{GRAPHQL_WS_ENDPOINT}}' + }, + msAuth: { + clientId: '{{MS_AUTHENTICATION_CLIENT_ID}}', + authority: '{{MS_AUTHENTICATION_AUTHORITY}}', + redirectUri: '{{MS_AUTHENTICATION_REDIRECT_URI}}', + postLogoutRedirectUri: '{{MS_AUTHENTICATION_POST_LOGOUT_REDIRECT_URI}}', + }, + telemetry: { + collectorSecret: '{{COLLECTOR_SECRET}}', + otlpEndpoint: '{{OTLP_ENDPOINT}}', + serviceName: '{{OTLP_SERVICE_NAME}}' + } +}; diff --git a/service/app-hub/dashboard/src/environments/environment.ts b/service/app-hub/dashboard/src/environments/environment.ts new file mode 100644 index 0000000..fef9011 --- /dev/null +++ b/service/app-hub/dashboard/src/environments/environment.ts @@ -0,0 +1,18 @@ +export const environment = { + production: false, + graphQl: { + endpoint: '', + ws: '', + }, + msAuth: { + clientId: '', + authority: '', + redirectUri: '', + postLogoutRedirectUri: '', + }, + telemetry: { + collectorSecret: '', + otlpEndpoint: '', + serviceName: '' + } +}; diff --git a/service/app-hub/dashboard/src/index.html b/service/app-hub/dashboard/src/index.html index 6fb35cf..fc77afd 100644 --- a/service/app-hub/dashboard/src/index.html +++ b/service/app-hub/dashboard/src/index.html @@ -6,8 +6,10 @@ + + - + diff --git a/service/app-hub/dashboard/src/m3-theme.scss b/service/app-hub/dashboard/src/m3-theme.scss new file mode 100644 index 0000000..1e31d5e --- /dev/null +++ b/service/app-hub/dashboard/src/m3-theme.scss @@ -0,0 +1,159 @@ +// This file was generated by running 'ng generate @angular/material:m3-theme'. +// Proceed with caution if making changes to this file. + +@use 'sass:map'; +@use '@angular/material' as mat; + +// Note: Color palettes are generated from primary: #17253D +$_palettes: ( + primary: ( + 0: #000000, + 10: #001b3f, + 20: #002f65, + 25: #003a79, + 30: #02458e, + 35: #1b519a, + 40: #2b5ea7, + 50: #4877c2, + 60: #6491de, + 70: #7fabfb, + 80: #abc7ff, + 90: #d7e3ff, + 95: #ecf0ff, + 98: #f9f9ff, + 99: #fdfbff, + 100: #ffffff, + ), + secondary: ( + 0: #000000, + 10: #131c2b, + 20: #283141, + 25: #333c4d, + 30: #3e4759, + 35: #4a5365, + 40: #565e71, + 50: #6e778b, + 60: #8891a5, + 70: #a3abc0, + 80: #bec6dc, + 90: #dae2f9, + 95: #ecf0ff, + 98: #f9f9ff, + 99: #fdfbff, + 100: #ffffff, + ), + tertiary: ( + 0: #000000, + 10: #28132e, + 20: #3f2844, + 25: #4b3350, + 30: #573e5c, + 35: #634a68, + 40: #705574, + 50: #8a6e8e, + 60: #a587a9, + 70: #c0a1c4, + 80: #ddbce0, + 90: #fad8fd, + 95: #ffebfe, + 98: #fff7fb, + 99: #fffbff, + 100: #ffffff, + ), + neutral: ( + 0: #000000, + 10: #1a1b1f, + 20: #2f3033, + 25: #3a3b3f, + 30: #46474a, + 35: #525256, + 40: #5e5e62, + 50: #77777a, + 60: #919094, + 70: #ababaf, + 80: #c7c6ca, + 90: #e3e2e6, + 95: #f2f0f4, + 98: #faf9fd, + 99: #fdfbff, + 100: #ffffff, + 4: #0d0e11, + 6: #121316, + 12: #1f1f23, + 17: #292a2d, + 22: #343538, + 24: #38393c, + 87: #dbd9dd, + 92: #e9e7ec, + 94: #efedf1, + 96: #f4f3f7, + ), + neutral-variant: ( + 0: #000000, + 10: #181c22, + 20: #2d3038, + 25: #383b43, + 30: #44474e, + 35: #4f525a, + 40: #5b5e66, + 50: #74777f, + 60: #8e9099, + 70: #a9abb4, + 80: #c4c6d0, + 90: #e0e2ec, + 95: #eff0fa, + 98: #f9f9ff, + 99: #fdfbff, + 100: #ffffff, + ), + error: ( + 0: #000000, + 10: #410002, + 20: #690005, + 25: #7e0007, + 30: #93000a, + 35: #a80710, + 40: #ba1a1a, + 50: #de3730, + 60: #ff5449, + 70: #ff897d, + 80: #ffb4ab, + 90: #ffdad6, + 95: #ffedea, + 98: #fff8f7, + 99: #fffbff, + 100: #ffffff, + ), +); + +$_rest: ( + secondary: map.get($_palettes, secondary), + neutral: map.get($_palettes, neutral), + neutral-variant: map.get($_palettes, neutral-variant), + error: map.get($_palettes, error), +); +$_primary: map.merge(map.get($_palettes, primary), $_rest); +$_tertiary: map.merge(map.get($_palettes, tertiary), $_rest); + +$light-theme: mat.define-theme(( + color: ( + theme-type: light, + primary: $_primary, + tertiary: $_tertiary, + use-system-variables: true, + ), + typography: ( + use-system-variables: true, + ), +)); +$dark-theme: mat.define-theme(( + color: ( + theme-type: dark, + primary: $_primary, + tertiary: $_tertiary, + use-system-variables: true, + ), + typography: ( + use-system-variables: true, + ), +)); diff --git a/service/app-hub/dashboard/src/main.ts b/service/app-hub/dashboard/src/main.ts index 35b00f3..f987f2a 100644 --- a/service/app-hub/dashboard/src/main.ts +++ b/service/app-hub/dashboard/src/main.ts @@ -1,6 +1,10 @@ -import { bootstrapApplication } from '@angular/platform-browser'; -import { appConfig } from './app/app.config'; -import { AppComponent } from './app/app.component'; +/// -bootstrapApplication(AppComponent, appConfig) - .catch((err) => console.error(err)); +import '@angular/common/locales/global/de'; +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig).catch((err) => + console.error(err) +); diff --git a/service/app-hub/dashboard/src/styles.scss b/service/app-hub/dashboard/src/styles.scss index 90d4ee0..6f96bf3 100644 --- a/service/app-hub/dashboard/src/styles.scss +++ b/service/app-hub/dashboard/src/styles.scss @@ -1 +1,8 @@ /* You can add global styles to this file, and also import other style files */ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +html, body { height: 100%; } +body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } diff --git a/service/app-hub/dashboard/tailwind.config.js b/service/app-hub/dashboard/tailwind.config.js new file mode 100644 index 0000000..73f743a --- /dev/null +++ b/service/app-hub/dashboard/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./src/**/*.{html,ts}"], + theme: { + extend: {}, + }, + plugins: [], +}; diff --git a/service/app-hub/dashboard/tsconfig.app.json b/service/app-hub/dashboard/tsconfig.app.json index 3775b37..3e50bc3 100644 --- a/service/app-hub/dashboard/tsconfig.app.json +++ b/service/app-hub/dashboard/tsconfig.app.json @@ -4,7 +4,9 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", - "types": [] + "types": [ + "@angular/localize" + ] }, "files": [ "src/main.ts" diff --git a/service/app-hub/dashboard/tsconfig.json b/service/app-hub/dashboard/tsconfig.json index a9ae8b0..ce1326c 100644 --- a/service/app-hub/dashboard/tsconfig.json +++ b/service/app-hub/dashboard/tsconfig.json @@ -1,5 +1,3 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ { "compileOnSave": false, "compilerOptions": { @@ -20,8 +18,10 @@ "module": "ES2022", "lib": [ "ES2022", - "dom" - ] + "dom", + "esnext.asynciterable" + ], + "allowSyntheticDefaultImports": true }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false, @@ -29,4 +29,4 @@ "strictInputAccessModifiers": true, "strictTemplates": true } -} +} \ No newline at end of file diff --git a/service/app-hub/dashboard/tsconfig.spec.json b/service/app-hub/dashboard/tsconfig.spec.json index 5fb748d..b4c6282 100644 --- a/service/app-hub/dashboard/tsconfig.spec.json +++ b/service/app-hub/dashboard/tsconfig.spec.json @@ -5,7 +5,8 @@ "compilerOptions": { "outDir": "./out-tsc/spec", "types": [ - "jasmine" + "jasmine", + "@angular/localize" ] }, "include": [