init dashboard
This commit is contained in:
parent
98aeb82ff6
commit
52c1a14609
|
|
@ -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;"]
|
||||||
|
|
@ -13,6 +13,9 @@
|
||||||
"root": "",
|
"root": "",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
|
"i18n": {
|
||||||
|
"sourceLocale": "de"
|
||||||
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:application",
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
|
@ -21,7 +24,8 @@
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js"
|
"zone.js",
|
||||||
|
"@angular/localize/init"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
|
|
@ -32,6 +36,8 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||||
|
"node_modules/@ngxpert/hot-toast/src/styles/styles.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": []
|
||||||
|
|
@ -42,7 +48,7 @@
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "500kB",
|
"maximumWarning": "500kB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|
@ -50,12 +56,24 @@
|
||||||
"maximumError": "4kB"
|
"maximumError": "4kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.development.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
|
|
@ -80,7 +98,8 @@
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
"zone.js",
|
"zone.js",
|
||||||
"zone.js/testing"
|
"zone.js/testing",
|
||||||
|
"@angular/localize/init"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
|
|
@ -91,6 +110,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
"@angular/material/prebuilt-themes/azure-blue.css",
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
],
|
],
|
||||||
"scripts": []
|
"scripts": []
|
||||||
|
|
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,20 +4,46 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build -c production",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "ng build --watch --configuration development",
|
||||||
"test": "ng test"
|
"test": "ng test"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^18.1.0",
|
"@angular/animations": "^18.1.0",
|
||||||
|
"@angular/cdk": "^18.1.1",
|
||||||
"@angular/common": "^18.1.0",
|
"@angular/common": "^18.1.0",
|
||||||
"@angular/compiler": "^18.1.0",
|
"@angular/compiler": "^18.1.0",
|
||||||
"@angular/core": "^18.1.0",
|
"@angular/core": "^18.1.0",
|
||||||
"@angular/forms": "^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": "^18.1.0",
|
||||||
"@angular/platform-browser-dynamic": "^18.1.0",
|
"@angular/platform-browser-dynamic": "^18.1.0",
|
||||||
"@angular/router": "^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",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.14.3"
|
"zone.js": "~0.14.3"
|
||||||
|
|
@ -26,13 +52,17 @@
|
||||||
"@angular-devkit/build-angular": "^18.1.1",
|
"@angular-devkit/build-angular": "^18.1.1",
|
||||||
"@angular/cli": "^18.1.1",
|
"@angular/cli": "^18.1.1",
|
||||||
"@angular/compiler-cli": "^18.1.0",
|
"@angular/compiler-cli": "^18.1.0",
|
||||||
|
"@angular/localize": "^18.1.1",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
"jasmine-core": "~5.1.0",
|
"jasmine-core": "~5.1.0",
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"postcss": "^8.4.39",
|
||||||
|
"tailwindcss": "^3.4.6",
|
||||||
"typescript": "~5.5.2"
|
"typescript": "~5.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,336 +1,3 @@
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
<app-app-shell>
|
||||||
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
|
<router-outlet></router-outlet>
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
|
</app-app-shell>
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * to get started with your project! * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
--bright-blue: oklch(51.01% 0.274 263.83);
|
|
||||||
--electric-violet: oklch(53.18% 0.28 296.97);
|
|
||||||
--french-violet: oklch(47.66% 0.246 305.88);
|
|
||||||
--vivid-pink: oklch(69.02% 0.277 332.77);
|
|
||||||
--hot-red: oklch(61.42% 0.238 15.34);
|
|
||||||
--orange-red: oklch(63.32% 0.24 31.68);
|
|
||||||
|
|
||||||
--gray-900: oklch(19.37% 0.006 300.98);
|
|
||||||
--gray-700: oklch(36.98% 0.014 302.71);
|
|
||||||
--gray-400: oklch(70.9% 0.015 304.04);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--orange-red) 0%,
|
|
||||||
var(--vivid-pink) 50%,
|
|
||||||
var(--electric-violet) 100%
|
|
||||||
);
|
|
||||||
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
|
|
||||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
box-sizing: border-box;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: 3.125rem;
|
|
||||||
color: var(--gray-900);
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 100%;
|
|
||||||
letter-spacing: -0.125rem;
|
|
||||||
margin: 0;
|
|
||||||
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
|
||||||
"Segoe UI Symbol";
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--gray-700);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
box-sizing: inherit;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.angular-logo {
|
|
||||||
max-width: 9.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-around;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 700px;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content h1 {
|
|
||||||
margin-top: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
width: 1px;
|
|
||||||
background: var(--red-to-pink-to-purple-vertical-gradient);
|
|
||||||
margin-inline: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
|
|
||||||
color: var(--pill-accent);
|
|
||||||
padding-inline: 0.75rem;
|
|
||||||
padding-block: 0.375rem;
|
|
||||||
border-radius: 2.75rem;
|
|
||||||
border: 0;
|
|
||||||
transition: background 0.3s ease;
|
|
||||||
font-family: var(--inter-font);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4rem;
|
|
||||||
letter-spacing: -0.00875rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill:hover {
|
|
||||||
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group .pill:nth-child(6n + 1) {
|
|
||||||
--pill-accent: var(--bright-blue);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 2) {
|
|
||||||
--pill-accent: var(--french-violet);
|
|
||||||
}
|
|
||||||
.pill-group .pill:nth-child(6n + 3),
|
|
||||||
.pill-group .pill:nth-child(6n + 4),
|
|
||||||
.pill-group .pill:nth-child(6n + 5) {
|
|
||||||
--pill-accent: var(--hot-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pill-group svg {
|
|
||||||
margin-inline-start: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.73rem;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links path {
|
|
||||||
transition: fill 0.3s ease;
|
|
||||||
fill: var(--gray-400);
|
|
||||||
}
|
|
||||||
|
|
||||||
.social-links a:hover svg path {
|
|
||||||
fill: var(--gray-900);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 650px) {
|
|
||||||
.content {
|
|
||||||
flex-direction: column;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1px;
|
|
||||||
width: 100%;
|
|
||||||
background: var(--red-to-pink-to-purple-horizontal-gradient);
|
|
||||||
margin-block: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<main class="main">
|
|
||||||
<div class="content">
|
|
||||||
<div class="left-side">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 982 239"
|
|
||||||
fill="none"
|
|
||||||
class="angular-logo"
|
|
||||||
>
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path
|
|
||||||
fill="url(#b)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="url(#c)"
|
|
||||||
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
|
||||||
/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<radialGradient
|
|
||||||
id="c"
|
|
||||||
cx="0"
|
|
||||||
cy="0"
|
|
||||||
r="1"
|
|
||||||
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#FF41F8" />
|
|
||||||
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
|
|
||||||
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
|
|
||||||
</radialGradient>
|
|
||||||
<linearGradient
|
|
||||||
id="b"
|
|
||||||
x1="0"
|
|
||||||
x2="982"
|
|
||||||
y1="192"
|
|
||||||
y2="192"
|
|
||||||
gradientUnits="userSpaceOnUse"
|
|
||||||
>
|
|
||||||
<stop stop-color="#F0060B" />
|
|
||||||
<stop offset="0" stop-color="#F0070C" />
|
|
||||||
<stop offset=".526" stop-color="#CC26D5" />
|
|
||||||
<stop offset="1" stop-color="#7702FF" />
|
|
||||||
</linearGradient>
|
|
||||||
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
<h1>Hello, {{ title }}</h1>
|
|
||||||
<p>Congratulations! Your app is running. 🎉</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider" role="separator" aria-label="Divider"></div>
|
|
||||||
<div class="right-side">
|
|
||||||
<div class="pill-group">
|
|
||||||
@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) {
|
|
||||||
<a
|
|
||||||
class="pill"
|
|
||||||
[href]="item.link"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<span>{{ item.title }}</span>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
height="14"
|
|
||||||
viewBox="0 -960 960 960"
|
|
||||||
width="14"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="social-links">
|
|
||||||
<a
|
|
||||||
href="https://github.com/angular/angular"
|
|
||||||
aria-label="Github"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="25"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 25 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Github"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://twitter.com/angular"
|
|
||||||
aria-label="Twitter"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Twitter"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
|
|
||||||
aria-label="Youtube"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="29"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 29 20"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
alt="Youtube"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
|
|
||||||
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
|
|
||||||
|
|
||||||
|
|
||||||
<router-outlet />
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { AppShellComponent } from './core/components/app-shell/app-shell.component';
|
||||||
|
import { MsalService } from '@azure/msal-angular';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [AppShellComponent, RouterOutlet],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss'
|
styleUrl: './app.component.scss',
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
title = 'dashboard';
|
title = 'dashboard';
|
||||||
|
|
||||||
|
constructor(private readonly authService: MsalService) {
|
||||||
|
this.authService.handleRedirectObservable().subscribe();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { provideRouter } from '@angular/router';
|
||||||
|
|
||||||
import { routes } from './app.routes';
|
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 = {
|
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(),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,57 @@
|
||||||
import { Routes } from '@angular/router';
|
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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<mat-sidenav-container class="h-full">
|
||||||
|
<mat-sidenav
|
||||||
|
#drawer
|
||||||
|
class="w-52"
|
||||||
|
fixedInViewport
|
||||||
|
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
|
||||||
|
[mode]="(isHandset$ | async) ? 'over' : 'side'"
|
||||||
|
[opened]="false"
|
||||||
|
>
|
||||||
|
<mat-toolbar color="primary">Navigation</mat-toolbar>
|
||||||
|
<mat-nav-list>
|
||||||
|
<a
|
||||||
|
mat-list-item
|
||||||
|
routerLink="/control-center"
|
||||||
|
routerLinkActive="!bg-yellow-400"
|
||||||
|
>Dashboard Leitstelle</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
*dksRole="['manager', 'admin']"
|
||||||
|
mat-list-item
|
||||||
|
routerLink="/managers"
|
||||||
|
routerLinkActive="!bg-yellow-400"
|
||||||
|
>Dashboard Manager</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
*dksRole="['manager', 'admin']"
|
||||||
|
mat-list-item
|
||||||
|
routerLink="/accounting"
|
||||||
|
routerLinkActive="!bg-yellow-400"
|
||||||
|
>Abrechnung</a
|
||||||
|
>
|
||||||
|
<a mat-list-item routerLink="/tickets" routerLinkActive="!bg-yellow-400"
|
||||||
|
>Ticket System</a
|
||||||
|
>
|
||||||
|
</mat-nav-list>
|
||||||
|
</mat-sidenav>
|
||||||
|
<mat-sidenav-content>
|
||||||
|
<mat-toolbar class="sticky top-0 z-10 flex items-center" color="primary">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Toggle sidenav"
|
||||||
|
mat-icon-button
|
||||||
|
(click)="drawer.toggle()"
|
||||||
|
>
|
||||||
|
<mat-icon aria-label="Toggle navigation icon">menu</mat-icon>
|
||||||
|
</button>
|
||||||
|
<span>{{ title$ | async }}</span>
|
||||||
|
<dks-avatar class="ml-auto"></dks-avatar>
|
||||||
|
</mat-toolbar>
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</mat-sidenav-content>
|
||||||
|
</mat-sidenav-container>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:root {
|
||||||
|
--mat-toolbar-container-background-color: #17253D;
|
||||||
|
}
|
||||||
|
|
@ -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<AppShellComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AppShellComponent]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AppShellComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<boolean>;
|
||||||
|
|
||||||
|
title$: Observable<string>;
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: `<div
|
||||||
|
class="flex items-center justify-center rounded-full h-10 w-10 bg-gray-100 text-blue-500 cursor-pointer transition ease-in-out hover:bg-white hover:shadow-md hover:scale-105 duration-200 "
|
||||||
|
[matMenuTriggerFor]="menu"
|
||||||
|
>
|
||||||
|
{{ userInitials$ | async }}
|
||||||
|
</div>
|
||||||
|
<mat-menu #menu="matMenu">
|
||||||
|
<button mat-menu-item (click)="logout()">Logout</button>
|
||||||
|
</mat-menu>`,
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: `<div class="flex flex-col items-center justify-start p-4">
|
||||||
|
<div class="text-lg font-bold pb-2">{{ ref.data.title }}</div>
|
||||||
|
<div class="pb-4 border-b border-gray-400">{{ ref.data.body }}</div>
|
||||||
|
<div class="pt-4">
|
||||||
|
<button mat-stroked-button class="mr-2" dialogClose>Abbrechen</button>
|
||||||
|
<button mat-stroked-button color="warn" [dialogClose]="true">Bestätigen</button>
|
||||||
|
</div>
|
||||||
|
</div>`,
|
||||||
|
standalone: true,
|
||||||
|
imports: [MatButtonModule, DialogCloseDirective],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class ConfirmDialogComponent {
|
||||||
|
ref: DialogRef<ConfirmDialogData> = inject(DialogRef);
|
||||||
|
}
|
||||||
|
|
@ -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<AccountInfo | undefined>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, Array<string>>();
|
||||||
|
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<MsAuthenticationModule> {
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string[]>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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, unknown> | string = '';
|
||||||
|
|
||||||
|
@Output() chartInit = new EventEmitter<echarts.ECharts>();
|
||||||
|
|
||||||
|
protected echartsInstance: echarts.ECharts | undefined;
|
||||||
|
|
||||||
|
protected subscription: Subscription | undefined;
|
||||||
|
|
||||||
|
constructor(protected readonly _el: ElementRef<HTMLElement>) {}
|
||||||
|
|
||||||
|
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<number> {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { EchartsDirective } from './echarts.directive';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [EchartsDirective],
|
||||||
|
exports: [EchartsDirective],
|
||||||
|
})
|
||||||
|
export class UtilNgxEchartsModule {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [CommonModule],
|
||||||
|
})
|
||||||
|
export class AvicennaDataAccessGraphqlModule {}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
query GetAnomalyByTypeReport {
|
||||||
|
anomalyAmountByType {
|
||||||
|
label
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetUpcomingTourNotificationReport {
|
||||||
|
upcomingTourNotificationReport {
|
||||||
|
label
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query GetAverageAnomalyResolutionTimeReport {
|
||||||
|
averageAnomalyResolutionTimeReport {
|
||||||
|
label
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
service/app-hub/dashboard/src/app/core/telemetry/external/faro-meta-attributes-span-processor.ts
vendored
Normal file
68
service/app-hub/dashboard/src/app/core/telemetry/external/faro-meta-attributes-span-processor.ts
vendored
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
return this.processor.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
50
service/app-hub/dashboard/src/app/core/telemetry/external/faro-user-action-span-processor.ts
vendored
Normal file
50
service/app-hub/dashboard/src/app/core/telemetry/external/faro-user-action-span-processor.ts
vendored
Normal file
|
|
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
return this.processor.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export function groupBy<T, K extends keyof any>(
|
||||||
|
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<K, T[]>);
|
||||||
|
}
|
||||||
|
|
@ -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<any> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -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: ` <div class="flex justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="notifyCopy($event)"
|
||||||
|
[cdkCopyToClipboard]="tour.operationId"
|
||||||
|
>
|
||||||
|
<mat-icon class="text-sm">content_copy</mat-icon>
|
||||||
|
</button>
|
||||||
|
{{ tour.operationId }}
|
||||||
|
</div>
|
||||||
|
<span>{{ tour.ordinanceType }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
[title]="!tour.healthInsurance ? 'Keine Krankenkasse hinterlegt' : ''"
|
||||||
|
[ngClass]="{ 'bg-yellow-200': !tour.healthInsurance }"
|
||||||
|
*ngIf="accountingState !== AccountingState.Billed; else diffTmpl"
|
||||||
|
>
|
||||||
|
{{ tour.revenue | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ng-template #diffTmpl>
|
||||||
|
{{
|
||||||
|
tour.revenueDeviation | currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
|
||||||
|
}}
|
||||||
|
</ng-template>`,
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: `<span *ngIf="withTourCount" class="text-left text-xs">
|
||||||
|
{{ tourStateMeta.count }} Touren
|
||||||
|
</span>
|
||||||
|
<span *ngIf="withRevenueSum" class="text-left text-xs">
|
||||||
|
{{
|
||||||
|
tourStateMeta.revenueSum
|
||||||
|
| currency : 'EUR' : 'symbol' : '1.2-2' : 'de-DE'
|
||||||
|
}}
|
||||||
|
</span>`,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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: `
|
||||||
|
<ng-container [ngSwitch]="state">
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="beforeBilledMenu"
|
||||||
|
aria-label="Abrechenbar Filter button"
|
||||||
|
*ngSwitchCase="AccountingState.Billable"
|
||||||
|
>
|
||||||
|
<mat-icon>filter_list</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="beforeBilledMenu"
|
||||||
|
aria-label="Abrechnung vorbereitet Filter button"
|
||||||
|
*ngSwitchCase="AccountingState.PreparedForBilling"
|
||||||
|
>
|
||||||
|
<mat-icon>filter_list</mat-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
[matMenuTriggerFor]="afterBilledMenu"
|
||||||
|
aria-label="Abgerechnet Filter button"
|
||||||
|
*ngSwitchCase="AccountingState.Billed"
|
||||||
|
>
|
||||||
|
<mat-icon>filter_list</mat-icon>
|
||||||
|
</button>
|
||||||
|
</ng-container>
|
||||||
|
<form #form="ngForm">
|
||||||
|
<mat-menu #beforeBilledMenu="matMenu">
|
||||||
|
<section
|
||||||
|
mat-menu-item
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
*ngFor="let item of filter.beforeBilled"
|
||||||
|
>
|
||||||
|
<mat-checkbox [(ngModel)]="item.checked" [name]="item.value">{{
|
||||||
|
item.text
|
||||||
|
}}</mat-checkbox>
|
||||||
|
</section>
|
||||||
|
</mat-menu>
|
||||||
|
<mat-menu #afterBilledMenu="matMenu">
|
||||||
|
<section
|
||||||
|
mat-menu-item
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
*ngFor="let item of filter.afterBilled"
|
||||||
|
>
|
||||||
|
<mat-checkbox [(ngModel)]="item.checked" [name]="item.value">{{
|
||||||
|
item.text
|
||||||
|
}}</mat-checkbox>
|
||||||
|
</section>
|
||||||
|
</mat-menu>
|
||||||
|
</form>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
imports: [
|
||||||
|
NgSwitch,
|
||||||
|
NgSwitchCase,
|
||||||
|
NgFor,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatIconModule,
|
||||||
|
MatMenuModule,
|
||||||
|
MatCheckboxModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AccountingLaneHeaderFilterComponent implements AfterViewInit {
|
||||||
|
@Input() state!: AccountingState;
|
||||||
|
@Output() filterChange = new EventEmitter<AccountingFilterArgs>();
|
||||||
|
@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<string, string[]>);
|
||||||
|
}),
|
||||||
|
// 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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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: `<div></div>
|
||||||
|
<ng-container [ngSwitch]="state">
|
||||||
|
<div *ngSwitchCase="AccountingState.Billable">Abrechenbar</div>
|
||||||
|
<div *ngSwitchCase="AccountingState.PreparedForBilling">
|
||||||
|
für Abrechnung vorbereitet
|
||||||
|
</div>
|
||||||
|
<div *ngSwitchCase="AccountingState.Billed">
|
||||||
|
Abgerechnet (mit Abweichung)
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<dks-accounting-lane-header-filter
|
||||||
|
[state]="state"
|
||||||
|
(filterChange)="filterChanged($event)"
|
||||||
|
></dks-accounting-lane-header-filter>`,
|
||||||
|
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<AccountingFilterArgs>();
|
||||||
|
|
||||||
|
AccountingState = AccountingState;
|
||||||
|
|
||||||
|
filterChanged(filter: AccountingFilterArgs) {
|
||||||
|
this.filterChange.emit(filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
<dks-accounting-lane-header
|
||||||
|
class="shadow"
|
||||||
|
[state]="accountingState"
|
||||||
|
(filterChange)="filterChanged($event)"
|
||||||
|
></dks-accounting-lane-header>
|
||||||
|
<ng-container *ngIf="tours$ | async as tours">
|
||||||
|
<cdk-virtual-scroll-viewport
|
||||||
|
itemSize="86"
|
||||||
|
(scrolledIndexChange)="fetchNextPage()"
|
||||||
|
>
|
||||||
|
<dks-accounting-item
|
||||||
|
*cdkVirtualFor="let tour of tours; trackBy: trackByFn"
|
||||||
|
class="mat-elevation-z4 animate-fadeIn"
|
||||||
|
[accountingState]="accountingState"
|
||||||
|
[tour]="tour"
|
||||||
|
(click)="openTourView(tour)"
|
||||||
|
>
|
||||||
|
</dks-accounting-item>
|
||||||
|
</cdk-virtual-scroll-viewport>
|
||||||
|
<ng-container *ngIf="accountingStateMeta$ | async as accountingStateMeta">
|
||||||
|
<dks-accounting-lane-footer
|
||||||
|
[tourStateMeta]="accountingStateMeta"
|
||||||
|
[withTourCount]="true"
|
||||||
|
[withRevenueSum]="accountingState !== AccountingState.Billed"
|
||||||
|
></dks-accounting-lane-footer>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
|
@ -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<AccountingState | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
private accountingFilter$ = new BehaviorSubject<AccountingFilterArgs>({});
|
||||||
|
|
||||||
|
@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<string>('');
|
||||||
|
accountingStateMeta$ = this.accountingState$.pipe(
|
||||||
|
filter(Boolean),
|
||||||
|
switchMap((accountingState) =>
|
||||||
|
this.accountingService.fetchAccountingStateMeta(accountingState)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
tours$: Observable<Tour[]>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const batchMap: Observable<Record<string, Tour & { ticket: Ticket }>> =
|
||||||
|
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<string, Tour & { ticket: Ticket }>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { ...existingTours, ...tours };
|
||||||
|
}, {} as Record<string, Tour & { ticket: Ticket }>)
|
||||||
|
);
|
||||||
|
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<Record<string, Tour & { ticket: Ticket }>> {
|
||||||
|
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<string, Tour & { ticket: Ticket }>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNormalizedCursor(cursor: string) {
|
||||||
|
return cursor === '' ? 'MA==' : cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChanged(filter: AccountingFilterArgs) {
|
||||||
|
this.accountingFilter$.next(filter);
|
||||||
|
this.cursor$.next('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: `
|
||||||
|
<div class="px-4 py-2 flex justify-between">
|
||||||
|
<div class="flex flex-col text-gray-600 text-sm">
|
||||||
|
<span class="font-medium">Startzeit</span>
|
||||||
|
<span>
|
||||||
|
{{ tour.startDate | date : 'dd.MM.yyyy' }}
|
||||||
|
{{ tour.startDate | date : 'HH:mm' }} Uhr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col text-gray-600 text-sm">
|
||||||
|
<span class="font-medium" *ngIf="tour.createdAt">Erstellt am</span>
|
||||||
|
<span *ngIf="tour.createdAt">
|
||||||
|
{{ tour.createdAt | date : 'dd.MM.yyyy' }}
|
||||||
|
{{ tour.createdAt | date : 'HH:mm' }} Uhr
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap md:flex-nowrap justify-between px-4 py-2"
|
||||||
|
*ngIf="tour.driverName || tour.codriverName || tour.carName"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col pb-1 md:pb-0"
|
||||||
|
*ngIf="tour.carName"
|
||||||
|
>
|
||||||
|
<span class="font-semibold pb-1">Fahrzeug</span>
|
||||||
|
{{ tour.carName }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-col pb-1 md:pb-0"
|
||||||
|
*ngIf="tour.driverName"
|
||||||
|
>
|
||||||
|
<span class="font-semibold pb-1">Fahrer</span>
|
||||||
|
<span>{{ tour.driverName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col" *ngIf="tour.codriverName">
|
||||||
|
<span class="font-semibold pb-1">Beifahrer</span>
|
||||||
|
<span>{{ tour.codriverName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col px-4 py-2">
|
||||||
|
<span class="font-semibold pb-1">Patient</span>
|
||||||
|
<span>{{ tour.patientName }} {{ tour.patientSurname }}</span>
|
||||||
|
<span>{{ tour.patientStreet }}</span>
|
||||||
|
<span>{{ tour.patientZip }} {{ tour.patientCity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap md:flex-nowrap px-4 py-2">
|
||||||
|
<div class="w-full md:w-1/2 flex flex-col pb-1 md:pb-0">
|
||||||
|
<span class="font-semibold pb-1">Start</span>
|
||||||
|
<span>{{ tour.startInstitution }}</span>
|
||||||
|
<span>{{ tour.startStreet }}</span>
|
||||||
|
<span>{{ tour.startZip }} {{ tour.startCity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 flex flex-col">
|
||||||
|
<span class="font-semibold pb-1">Ziel</span>
|
||||||
|
<span>{{ tour.targetInstitution }}</span>
|
||||||
|
<span>{{ tour.targetStreet }}</span>
|
||||||
|
<span>{{ tour.targetZip }} {{ tour.targetCity }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
imports: [MatTabsModule, MatIconModule, MatDividerModule, NgIf, DatePipe],
|
||||||
|
})
|
||||||
|
export class AccountingTourOverviewComponent {
|
||||||
|
@Input() tour!: Tour;
|
||||||
|
}
|
||||||
|
|
@ -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: `
|
||||||
|
<div class="grid grid-cols-4">
|
||||||
|
<mat-list class="col-span-2" role="list">
|
||||||
|
<div mat-subheader class="text-sm">Ticket Informationen</div>
|
||||||
|
<mat-list-item role="listitem">
|
||||||
|
Status:
|
||||||
|
<ng-container [ngSwitch]="tour.ticket.currentState">
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
*ngSwitchCase="TicketValidationState.Created"
|
||||||
|
>Touren angenommen
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
*ngSwitchCase="TicketValidationState.UserInteractionRequired"
|
||||||
|
>Genehmigung / Transportschein
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
*ngSwitchCase="TicketValidationState.TdUncertain"
|
||||||
|
>Transportschein unklar
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
*ngSwitchCase="TicketValidationState.DocumentsMissing"
|
||||||
|
>Dokumente fehlen
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="font-bold"
|
||||||
|
*ngSwitchCase="TicketValidationState.Archived"
|
||||||
|
>Archiviert
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
</mat-list-item>
|
||||||
|
<mat-list-item role="listitem">
|
||||||
|
Typ:
|
||||||
|
<span class="font-bold">
|
||||||
|
{{ tour.type }}
|
||||||
|
</span>
|
||||||
|
</mat-list-item>
|
||||||
|
<mat-list-item role="listitem">
|
||||||
|
Kategorie:
|
||||||
|
<span class="font-bold">
|
||||||
|
{{ tour.category }}
|
||||||
|
</span>
|
||||||
|
</mat-list-item>
|
||||||
|
</mat-list>
|
||||||
|
<mat-list
|
||||||
|
class="col-span-2 max-h-96 overflow-y-auto"
|
||||||
|
*ngIf="tour.ticket.errors.length > 0 || tour.ticket.notes.length > 0"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<div mat-subheader class="text-sm">Ticket Anmerkungen</div>
|
||||||
|
<mat-list-item
|
||||||
|
*ngFor="let error of tour.ticket.errors"
|
||||||
|
lines="2"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon class="!text-red-600">error</mat-icon>
|
||||||
|
<span matListItemTitle>{{ error.split(':').at(0) }}</span>
|
||||||
|
<span [title]="error.split(':').at(1)">{{
|
||||||
|
error.split(':').at(1)
|
||||||
|
}}</span>
|
||||||
|
</mat-list-item>
|
||||||
|
<mat-list-item
|
||||||
|
*ngFor="let note of tour.ticket.notes"
|
||||||
|
lines="2"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<mat-icon matListItemIcon class="!text-yellow-400">warning</mat-icon>
|
||||||
|
<span matListItemTitle>{{ note.split(':').at(0) }}</span>
|
||||||
|
<span [title]="note.split(':').at(1)">{{
|
||||||
|
note.split(':').at(1)
|
||||||
|
}}</span>
|
||||||
|
</mat-list-item>
|
||||||
|
</mat-list>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [],
|
||||||
|
imports: [
|
||||||
|
MatIconModule,
|
||||||
|
MatListModule,
|
||||||
|
MatDividerModule,
|
||||||
|
NgIf,
|
||||||
|
NgFor,
|
||||||
|
NgSwitch,
|
||||||
|
NgSwitchCase,
|
||||||
|
DatePipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AccountingTourTicketComponent {
|
||||||
|
@Input() tour!: Tour & { ticket: Ticket };
|
||||||
|
|
||||||
|
TicketValidationState = TicketValidationState;
|
||||||
|
}
|
||||||
|
|
@ -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: `<h3 class="py-2 px-3 !mb-0">
|
||||||
|
{{ ref.data.ordinanceType }} -
|
||||||
|
{{ ref.data.patientName }}
|
||||||
|
{{ ref.data.patientSurname }}
|
||||||
|
</h3>
|
||||||
|
<hr />
|
||||||
|
<mat-tab-group>
|
||||||
|
<mat-tab label="Übersicht">
|
||||||
|
<dks-accounting-tour-overview
|
||||||
|
[tour]="ref.data"
|
||||||
|
></dks-accounting-tour-overview>
|
||||||
|
</mat-tab>
|
||||||
|
<mat-tab label="Ticket">
|
||||||
|
<dks-accounting-tour-ticket
|
||||||
|
[tour]="ref.data"
|
||||||
|
></dks-accounting-tour-ticket>
|
||||||
|
</mat-tab>
|
||||||
|
</mat-tab-group> `,
|
||||||
|
styles: [],
|
||||||
|
imports: [
|
||||||
|
MatTabsModule,
|
||||||
|
MatIconModule,
|
||||||
|
AccountingTourOverviewComponent,
|
||||||
|
AccountingTourTicketComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AccountingTourViewComponent {
|
||||||
|
ref: DialogRef<Tour & { ticket: Ticket }, undefined | any> =
|
||||||
|
inject(DialogRef);
|
||||||
|
}
|
||||||
|
|
@ -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: `<section class="h-full p-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="col-span-1 md:col-span-5 flex justify-end items-center">
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
</div>
|
||||||
|
<dks-accounting-lane
|
||||||
|
[accountingState]="AccountingState.Billable"
|
||||||
|
></dks-accounting-lane>
|
||||||
|
<dks-accounting-lane
|
||||||
|
[accountingState]="AccountingState.PreparedForBilling"
|
||||||
|
></dks-accounting-lane>
|
||||||
|
<dks-accounting-lane [accountingState]="AccountingState.Billed"></dks-accounting-lane>
|
||||||
|
</section>`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
height: calc(100% - 64px);
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
grid-template-rows: 2rem 1fr;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class DashboardAccountingComponent {
|
||||||
|
AccountingState = AccountingState;
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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<void>();
|
||||||
|
|
||||||
|
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<GetAccountingToursQuery['accountingTours']> {
|
||||||
|
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<GetAccountingStateMetaQuery['accountingStateMeta']> {
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<void>();
|
||||||
|
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<PaginatedAnomaly> {
|
||||||
|
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<PaginatedAnomalyGroup> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply w-full rounded flex items-center;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
<span class="p-2 flex-grow text-left cursor-pointer" (click)="showSolutionDialog()">
|
||||||
|
{{ anomaly.description }}
|
||||||
|
</span>
|
||||||
|
|
@ -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<AnomalyListItemComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [AnomalyListItemComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AnomalyListItemComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<string>();
|
||||||
|
@Output() anomalyAccepted = new EventEmitter<string>();
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply flex flex-col h-full;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
<mat-list role="list" *ngIf="anomalies$ | async as anomalies">
|
||||||
|
<ng-container *ngIf="anomalies.length; else emptyListTmpl">
|
||||||
|
<mat-list-item
|
||||||
|
*ngFor="let anomaly of anomalies"
|
||||||
|
class="flex"
|
||||||
|
role="listitem"
|
||||||
|
>
|
||||||
|
<dks-anomaly-list-item
|
||||||
|
[anomaly]="anomaly"
|
||||||
|
(anomalySetToSleep)="setAnomalyToSleep($event)"
|
||||||
|
(anomalyAccepted)="acceptAnomaly($event)"
|
||||||
|
></dks-anomaly-list-item>
|
||||||
|
</mat-list-item>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #emptyListTmpl>
|
||||||
|
<mat-list-item
|
||||||
|
>Alle Mitarbeiter sind auf einem Fahrzeug angemeldet!</mat-list-item
|
||||||
|
>
|
||||||
|
</ng-template>
|
||||||
|
</mat-list>
|
||||||
|
<mat-paginator
|
||||||
|
class="mt-auto"
|
||||||
|
[length]="totalCount$ | async"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="[]"
|
||||||
|
(page)="changePage($event)"
|
||||||
|
aria-label="Select page"
|
||||||
|
>
|
||||||
|
</mat-paginator>
|
||||||
|
|
@ -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<AnomalyListComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [AnomalyListComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AnomalyListComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<string>(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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply m-4;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<h2>{{ ref.data.title }}</h2>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<article class="my-4 mx-2">
|
||||||
|
<div class="pb-4" *ngFor="let target of ref.data.solution">
|
||||||
|
<span class="font-bold text-base">{{ target.name }}</span>
|
||||||
|
<ul class="ml-4">
|
||||||
|
<li class="list-disc" *ngFor="let step of target.steps">
|
||||||
|
{{ step.description }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div class="flex justify-end mt-2">
|
||||||
|
<button mat-button color="primary" (click)="ref.close('sleep')">
|
||||||
|
<mat-icon>schedule</mat-icon> Stummschalten
|
||||||
|
</button>
|
||||||
|
<button mat-button color="warn" (click)="ref.close('accept')">
|
||||||
|
<mat-icon>check_circle_outline</mat-icon> Akzeptieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
@ -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<AnomalySolutionDialogComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [AnomalySolutionDialogComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AnomalySolutionDialogComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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'>
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply flex flex-col h-full;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<ng-container *ngIf="anomalies$ | async as anomalies">
|
||||||
|
<mat-tree
|
||||||
|
*ngIf="anomalies.length; else emptyListTmpl"
|
||||||
|
[dataSource]="dataSource"
|
||||||
|
[treeControl]="treeControl"
|
||||||
|
>
|
||||||
|
<mat-tree-node *matTreeNodeDef="let node" matTreeNodePadding>
|
||||||
|
<dks-anomaly-list-item
|
||||||
|
[anomaly]="node.anomaly"
|
||||||
|
(anomalySetToSleep)="setAnomalyToSleep($event)"
|
||||||
|
(anomalyAccepted)="acceptAnomaly($event)"
|
||||||
|
></dks-anomaly-list-item>
|
||||||
|
</mat-tree-node>
|
||||||
|
|
||||||
|
<mat-tree-node
|
||||||
|
class="flex"
|
||||||
|
*matTreeNodeDef="let node; when: hasChild"
|
||||||
|
matTreeNodePadding
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matTreeNodeToggle
|
||||||
|
[attr.aria-label]="'Toggle ' + node.name"
|
||||||
|
>
|
||||||
|
<mat-icon class="mat-icon-rtl-mirror">
|
||||||
|
{{ treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right' }}
|
||||||
|
</mat-icon>
|
||||||
|
</button>
|
||||||
|
{{ node.name }}
|
||||||
|
<div class="ml-auto bg-blue-400 rounded-full w-5 h-5 flex justify-center items-center text-white font-bold">{{ node?.childCount }}</div>
|
||||||
|
</mat-tree-node>
|
||||||
|
</mat-tree>
|
||||||
|
<ng-template #emptyListTmpl>
|
||||||
|
<mat-list-item
|
||||||
|
>Herzlichen Glückwunsch, es liegen keine Anomalien vor!</mat-list-item
|
||||||
|
>
|
||||||
|
</ng-template>
|
||||||
|
</ng-container>
|
||||||
|
<mat-paginator
|
||||||
|
#paginator
|
||||||
|
class="mt-auto"
|
||||||
|
[length]="totalCount$ | async"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[pageSizeOptions]="[]"
|
||||||
|
(page)="changePage($event)"
|
||||||
|
aria-label="Select page"
|
||||||
|
>
|
||||||
|
</mat-paginator>
|
||||||
|
|
@ -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<AnomalyTreeComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [AnomalyTreeComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AnomalyTreeComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<AnomalyFlatNode>(
|
||||||
|
(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<string>(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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
<div class="flex flex-col m-4">
|
||||||
|
<div class="flex flex-wrap lg:flex-nowrap">
|
||||||
|
<dks-tours-per-time-kpi
|
||||||
|
class="w-full md:w-1/2 p-2"
|
||||||
|
[ordinanceType]="'BTW'"
|
||||||
|
></dks-tours-per-time-kpi>
|
||||||
|
<dks-tours-per-time-kpi
|
||||||
|
class="w-full md:w-1/2 p-2"
|
||||||
|
[ordinanceType]="'TSW'"
|
||||||
|
></dks-tours-per-time-kpi>
|
||||||
|
<dks-tours-per-time-kpi
|
||||||
|
class="w-full md:w-1/2 p-2"
|
||||||
|
[ordinanceType]="'KTW'"
|
||||||
|
></dks-tours-per-time-kpi>
|
||||||
|
</div>
|
||||||
|
<div class="flex lg:flex-nowrap flex-wrap-reverse">
|
||||||
|
<mat-card class="w-full md:flex-grow lg:w-2/5 m-2 !flex flex-col">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title> Anomalien </mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content class="flex-grow">
|
||||||
|
<dks-anomaly-tree></dks-anomaly-tree>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
<mat-card class="w-full md:flex-grow lg:w-2/5 m-2 !flex flex-col">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title> Mitarbeiter ohne Tour </mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content class="flex-grow">
|
||||||
|
<dks-anomaly-list></dks-anomaly-list>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
<mat-card class="w-full lg:w-1/5 m-2">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title> Nächste Touren ohne Anfahrt </mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<!-- <dks-upcoming-tours></dks-upcoming-tours> -->
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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<ControlCenterDashboardComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ControlCenterDashboardComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ControlCenterDashboardComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-header class="flex justify-center">
|
||||||
|
<mat-card-title>{{ ordinanceType }}</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content *ngIf="toursPerTimeKPI$ | async as kpis">
|
||||||
|
<div class="flex">
|
||||||
|
<div
|
||||||
|
*ngFor="let kpi of kpis; index as idx"
|
||||||
|
class="w-1/3 flex flex-col justify-center items-center border-blue-400"
|
||||||
|
[ngClass]="{'border-b-2': (currentTimeSlot$ | async) === idx}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="p-4 text-base"
|
||||||
|
>
|
||||||
|
{{ kpi.kpi.value.toString().replace('.', ',') }}
|
||||||
|
</div>
|
||||||
|
<div class="font-bold">
|
||||||
|
{{ kpi.interval.start | date: 'HH:mm' }} -
|
||||||
|
{{ kpi.interval.end | date: 'HH:mm' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
|
@ -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<ToursPerTimeKPIComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ToursPerTimeKPIComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ToursPerTimeKPIComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<number>;
|
||||||
|
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply m-4;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
<h2 class="!mb-1">{{ ref.data.operationId }} - {{ ref.data.ordinanceType }}</h2>
|
||||||
|
<div class="mb-2 flex">
|
||||||
|
<div class="flex flex-col w-1/2 text-gray-600 text-sm">
|
||||||
|
<span class="font-medium">Startzeit</span>
|
||||||
|
<span>
|
||||||
|
{{ ref.data.startDate | date: 'HH:mm' }} Uhr
|
||||||
|
{{ ref.data.startDate | date: 'dd.MM.yyyy' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col w-1/2 text-gray-600 text-sm">
|
||||||
|
<span class="font-medium" *ngIf="ref.data.createdAt">Erstellt am</span>
|
||||||
|
<span>
|
||||||
|
{{ ref.data.createdAt | date: 'HH:mm' }} Uhr
|
||||||
|
{{ ref.data.createdAt | date: 'dd.MM.yyyy' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<mat-divider></mat-divider>
|
||||||
|
<div
|
||||||
|
class="flex flex-wrap md:flex-nowrap p-2"
|
||||||
|
*ngIf="ref.data.driverName || ref.data.codriverName || ref.data.carName"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full md:w-1/3 flex flex-col pb-1 md:pb-0"
|
||||||
|
*ngIf="ref.data.carName"
|
||||||
|
>
|
||||||
|
<span class="font-semibold pb-1">Fahrzeug</span>
|
||||||
|
{{ ref.data.carName }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="w-full md:w-1/3 flex flex-col pb-1 md:pb-0"
|
||||||
|
*ngIf="ref.data.driverName"
|
||||||
|
>
|
||||||
|
<span class="font-semibold pb-1">Fahrer</span>
|
||||||
|
<span>{{ ref.data.driverName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/3 flex flex-col" *ngIf="ref.data.codriverName">
|
||||||
|
<span class="font-semibold pb-1">Beifahrer</span>
|
||||||
|
<span>{{ ref.data.codriverName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col p-2">
|
||||||
|
<span class="font-semibold pb-1">Patient</span>
|
||||||
|
<span>{{ ref.data.patientName }} {{ ref.data.patientSurname }}</span>
|
||||||
|
<span>{{ ref.data.patientStreet }}</span>
|
||||||
|
<span>{{ ref.data.patientZip }} {{ ref.data.patientCity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap md:flex-nowrap p-2">
|
||||||
|
<div class="w-full md:w-1/2 flex flex-col pb-1 md:pb-0">
|
||||||
|
<span class="font-semibold pb-1">Start</span>
|
||||||
|
<span>{{ ref.data.startInstitution }}</span>
|
||||||
|
<span>{{ ref.data.startStreet }}</span>
|
||||||
|
<span>{{ ref.data.startZip }} {{ ref.data.startCity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full md:w-1/2 flex flex-col">
|
||||||
|
<span class="font-semibold pb-1">Ziel</span>
|
||||||
|
<span>{{ ref.data.targetInstitution }}</span>
|
||||||
|
<span>{{ ref.data.targetStreet }}</span>
|
||||||
|
<span>{{ ref.data.targetZip }} {{ ref.data.targetCity }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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<TourViewDialogComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [TourViewDialogComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(TourViewDialogComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<Partial<Tour>>>(DialogRef);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<div
|
||||||
|
class="flex flex-col justify-between flex-wrap m-2 xl:m-4"
|
||||||
|
*ngIf="upcomingTours$ | async as upcomingTours"
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="upcomingTours.length; else emptyTmpl">
|
||||||
|
<div
|
||||||
|
class="py-2 xl:px-2 flex flex-nowrap items-center"
|
||||||
|
*ngFor="let tour of upcomingTours"
|
||||||
|
>
|
||||||
|
<div class="h-4 w-4 mr-2 rounded-full" [ngClass]="tour.colorClass"></div>
|
||||||
|
<div class="cursor-pointer" (click)="openTourViewDialog(tour)">
|
||||||
|
{{ tour.operationId }} - {{ tour.startDate | date: 'HH:mm' }} Uhr
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #emptyTmpl
|
||||||
|
>Keine Touren in den nächsten 20 Minuten ohne Anfahrt</ng-template
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
@ -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<UpcomingToursComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [UpcomingToursComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(UpcomingToursComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<Tour>) {
|
||||||
|
this.diaolog.open(TourViewDialogComponent, {
|
||||||
|
data: tour,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<div
|
||||||
|
*ngIf="echartsOptions$ | async as echartsOptions"
|
||||||
|
dksEcharts
|
||||||
|
[defaultHeight]="300"
|
||||||
|
[options]="echartsOptions"
|
||||||
|
[extentions]="echartsExtensions"
|
||||||
|
></div>
|
||||||
|
|
@ -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<AverageRevenueMetricComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AverageRevenueMetricComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AverageRevenueMetricComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<EChartsOption> = 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply flex justify-center items-center;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<div class="m-auto flex flex-col" *ngIf="metricsSummary$ | async as summary">
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<span class="text-base">{{
|
||||||
|
summary.averageRevenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||||
|
}} Ø Umsatz</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<span class="text-base">{{
|
||||||
|
(summary.availableTime / summary.tourAmount) | number : '1.2-2'
|
||||||
|
}} Index</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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<AverageRevenueSummaryComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AverageRevenueSummaryComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AverageRevenueSummaryComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<TimePerTourMetric[]>([]);
|
||||||
|
|
||||||
|
metricsSummary$ = this._metrics$.pipe(
|
||||||
|
map((metrics) => summarizeMetrics(metrics))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<div
|
||||||
|
*ngIf="echartsOptions$ | async as echartsOptions"
|
||||||
|
dksEcharts
|
||||||
|
[defaultHeight]="300"
|
||||||
|
[options]="echartsOptions"
|
||||||
|
[extentions]="echartsExtensions"
|
||||||
|
></div>
|
||||||
|
|
@ -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<BreakevenMetricComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [BreakevenMetricComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(BreakevenMetricComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<EChartsOption> = 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[])
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
:host {
|
||||||
|
@apply flex justify-center items-center;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<div class="m-auto flex flex-col" *ngIf="metricsSummary$ | async as summary">
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<span class="text-base">{{
|
||||||
|
summary.revenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||||
|
}}</span>
|
||||||
|
<div class="p-1 text-sm" title="Umsatz">
|
||||||
|
<mat-icon>euro</mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<span class="text-base">{{
|
||||||
|
summary.personnelCost | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||||
|
}}</span>
|
||||||
|
<div class="p-1 text-sm" title="Personalkosten">
|
||||||
|
<mat-icon>people</mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<span class="text-base">{{
|
||||||
|
summary.consumptionCost | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||||
|
}}</span>
|
||||||
|
<div class="p-1 text-sm" title="Verbrauch">
|
||||||
|
<mat-icon>local_shipping</mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<span class="text-base">{{
|
||||||
|
summary.breakEven | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||||
|
}}</span>
|
||||||
|
<div class="p-1 text-sm" title="Deckungsbeitrag">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end text-yellow-500">
|
||||||
|
<span class="text-base"
|
||||||
|
>+{{
|
||||||
|
summary.forecastRevenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
<div class="p-1 text-sm" title="Umsatz Vorausschau">
|
||||||
|
<mat-icon>account_balance</mat-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -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<BreakevenSummaryComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [BreakevenSummaryComponent],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(BreakevenSummaryComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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<TimePerTourMetric[]>([]);
|
||||||
|
|
||||||
|
metricsSummary$ = this._metrics$.pipe(
|
||||||
|
map((metrics) => summarizeMetrics(metrics))
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue