Compare commits
360 Commits
b0efd3aa0f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3edb8a053c | ||
|
|
aedc7ccae5 | ||
|
|
bdaf0ec263 | ||
|
|
538f9ca487 | ||
|
|
5ef7e648eb | ||
|
|
74880d9ccc | ||
|
|
7f035f0c18 | ||
|
|
7fe04f55fe | ||
|
|
2ceebea533 | ||
|
|
95c884bc07 | ||
|
|
05b416855b | ||
|
|
b4dd42c8a5 | ||
|
|
41910e450e | ||
|
|
f2a5f2083a | ||
|
|
92b712d7ce | ||
|
|
be9954ac29 | ||
|
|
f25063074d | ||
|
|
9192111b12 | ||
|
|
6f88a11771 | ||
|
|
4c45c3b9ca | ||
|
|
484902b788 | ||
|
|
3266e8b2d5 | ||
|
|
77f14313ae | ||
|
|
f43ddccc46 | ||
|
|
28612f9cd0 | ||
|
|
d6703c8870 | ||
|
|
18c9c886ec | ||
|
|
4c6fe0db03 | ||
|
|
6cb753c040 | ||
|
|
52551b3243 | ||
|
|
f7d1a36e0f | ||
|
|
8580032ff9 | ||
|
|
001b237dd7 | ||
|
|
af21b180f1 | ||
|
|
fefff9419d | ||
|
|
27c60c6742 | ||
|
|
c8ec763aac | ||
|
|
d163df0d96 | ||
|
|
12d3a17f60 | ||
|
|
f3339ccafd | ||
|
|
fab8a02ce9 | ||
|
|
eb0585072d | ||
|
|
a596422056 | ||
|
|
531fa93b70 | ||
|
|
72341abb23 | ||
|
|
9c218b2a1d | ||
|
|
d38d3191c5 | ||
|
|
112f537904 | ||
|
|
25b6c5c3b0 | ||
|
|
398d13bf1b | ||
|
|
91b7c8d40f | ||
|
|
7dfef4b16a | ||
|
|
0397f23196 | ||
|
|
0865d61450 | ||
|
|
2eb8f3a255 | ||
|
|
22321a7ac9 | ||
|
|
c03802e97f | ||
|
|
1485c0c92c | ||
|
|
44ecbfa417 | ||
|
|
927a807c4d | ||
|
|
29a79ce0a9 | ||
|
|
2166744c63 | ||
|
|
81239f41ae | ||
|
|
584593ba71 | ||
|
|
4b83ff01cf | ||
|
|
8c88aa843c | ||
|
|
8a9cd72718 | ||
|
|
2484d057fb | ||
|
|
941b914fa9 | ||
|
|
bd683d021a | ||
|
|
124b1c1e59 | ||
|
|
6820fa9eed | ||
|
|
3daa6b1dbb | ||
|
|
9c7ad37233 | ||
|
|
0286670b81 | ||
|
|
02a0ce5891 | ||
|
|
47e0efeb80 | ||
|
|
b62c477d50 | ||
|
|
653a31ce63 | ||
|
|
57ffdecb10 | ||
|
|
11bd68200b | ||
|
|
2c92ca0866 | ||
|
|
a9ccdfc9ab | ||
|
|
f4f4c28cb7 | ||
|
|
d6fcf95795 | ||
|
|
6c2707ff47 | ||
|
|
420f1da114 | ||
|
|
5aa7618832 | ||
|
|
35171891a3 | ||
|
|
2df1ee1022 | ||
|
|
7fe842aa93 | ||
|
|
cdf4869548 | ||
|
|
bb115a9a4f | ||
|
|
da87ebc5c8 | ||
|
|
5b4eb7ff51 | ||
|
|
3254563458 | ||
|
|
5252ec5998 | ||
|
|
2d9cd74375 | ||
|
|
f4e0620b49 | ||
|
|
35e34b96d1 | ||
|
|
fb79817136 | ||
|
|
89dc26b0d2 | ||
|
|
c8616f7bbe | ||
|
|
b0b9952a2d | ||
|
|
8da1457e4d | ||
|
|
7dc64ca972 | ||
|
|
1b038ac844 | ||
|
|
cbc476b09a | ||
|
|
306f469634 | ||
|
|
772baea4ed | ||
|
|
f3f6e25e9c | ||
|
|
43fe9e2065 | ||
|
|
30f156934c | ||
|
|
b108d63106 | ||
|
|
b53762cf5c | ||
|
|
629548bfdd | ||
|
|
174d67cfd8 | ||
|
|
57baca292a | ||
|
|
0c02e6f1c9 | ||
|
|
dcf22d08fb | ||
|
|
867031d3c3 | ||
|
|
0c3eb4cc5a | ||
|
|
d4d0c91400 | ||
|
|
9147cec40f | ||
|
|
e732971cdc | ||
|
|
9db92a2728 | ||
|
|
1d815d4265 | ||
|
|
5aece28eb1 | ||
|
|
0f570ac5b0 | ||
|
|
8850b0ffda | ||
|
|
6df31455a9 | ||
|
|
05c1c9c0cf | ||
|
|
9f43fdc820 | ||
|
|
763f5293bc | ||
|
|
0e7b2e53aa | ||
|
|
7a4e6d92c2 | ||
|
|
3e998d6644 | ||
|
|
b6ab6a11f9 | ||
|
|
a8388b27b9 | ||
|
|
b1ac0f87f1 | ||
|
|
036630f598 | ||
|
|
2ce7c54697 | ||
|
|
ae5798bcdf | ||
|
|
93b4859700 | ||
|
|
b011ab9862 | ||
|
|
9649eec907 | ||
|
|
8117ebdf45 | ||
|
|
e8477640e2 | ||
|
|
9e84386a5d | ||
|
|
93809a85a4 | ||
|
|
f9f358a678 | ||
|
|
539ebd5ff6 | ||
|
|
c8f3d91f9c | ||
|
|
37bbd6a9b3 | ||
|
|
2d9a7118c6 | ||
|
|
699ebef7bd | ||
|
|
e69934ff51 | ||
|
|
f25c527e71 | ||
|
|
fb36561cb9 | ||
|
|
6e98a98670 | ||
|
|
5f97731e2b | ||
|
|
c711a6a132 | ||
|
|
b72b9d665b | ||
|
|
9c411be38c | ||
|
|
3a9543f7a7 | ||
|
|
6036c48332 | ||
|
|
7e41e5131f | ||
|
|
6756bbf0f8 | ||
|
|
a955564ee3 | ||
|
|
c496939004 | ||
|
|
bb06618919 | ||
|
|
eae8ea37d0 | ||
|
|
710d780a3a | ||
|
|
8097246049 | ||
|
|
e6aafd6b0c | ||
|
|
984c776b2a | ||
|
|
a84e8c529f | ||
|
|
04b9a0dc1d | ||
|
|
7d6263b6fb | ||
|
|
4e8221c892 | ||
|
|
d75d9ce578 | ||
|
|
b006e3a993 | ||
|
|
5e9c7e9bfe | ||
|
|
3e0b1e98bb | ||
|
|
94051b69f9 | ||
|
|
629385fa5c | ||
|
|
a446ce80ee | ||
|
|
71dd37bb0e | ||
|
|
08370cf898 | ||
|
|
fa92004d94 | ||
|
|
3753babf5f | ||
|
|
87cbdca79c | ||
|
|
bb68327604 | ||
|
|
2db9da2394 | ||
|
|
c3fc8e0a4a | ||
|
|
eff606e59a | ||
|
|
f1ba9d4e4d | ||
|
|
7bc13505b2 | ||
|
|
6da0408140 | ||
|
|
ad6d89847e | ||
|
|
5496254acb | ||
|
|
8fcbf6cfcd | ||
|
|
974f468766 | ||
|
|
0fb6d184bd | ||
|
|
48d634295a | ||
|
|
0246e34de4 | ||
|
|
ba0cb732d9 | ||
|
|
91b76b8e8d | ||
|
|
bb662bf856 | ||
|
|
2765d06836 | ||
|
|
b8b5c36a60 | ||
|
|
31a54deb2d | ||
|
|
71f120aa27 | ||
|
|
77c939697c | ||
|
|
234608973e | ||
|
|
8af8e14878 | ||
|
|
e4b56faf75 | ||
|
|
100dab06ed | ||
|
|
e7d120c477 | ||
|
|
2bf02af96f | ||
|
|
9ca5ee9e66 | ||
|
|
bc20f3869d | ||
|
|
06aa3c8f3e | ||
|
|
8d1b5ceddc | ||
|
|
806eaaeff7 | ||
|
|
c107738625 | ||
|
|
9b05f21ccc | ||
|
|
3b61dcb31b | ||
|
|
f8bfea039c | ||
|
|
136d3151cf | ||
|
|
ba1b0d8e79 | ||
|
|
0b7542488e | ||
|
|
3098ce67f0 | ||
|
|
0a20f91ba6 | ||
|
|
975d3b726f | ||
|
|
423c87ca11 | ||
|
|
c1ed09a21d | ||
|
|
4fe64382f3 | ||
|
|
e86de5cefe | ||
|
|
63e1b85a44 | ||
|
|
cdd26931a1 | ||
|
|
638b7bf519 | ||
|
|
46ba692af0 | ||
|
|
90b9616d50 | ||
|
|
85860ae9f0 | ||
|
|
421e1f5425 | ||
|
|
97eb40e1c6 | ||
|
|
b68eb10ad4 | ||
|
|
86b35e9925 | ||
|
|
ad6642b5e7 | ||
|
|
e76c8d9bd2 | ||
|
|
d4335960bf | ||
|
|
9457233c7d | ||
|
|
9a8a0501a5 | ||
|
|
ce1dacda9b | ||
|
|
7a9fbc97dd | ||
|
|
f79c225b71 | ||
|
|
afdcb6b92f | ||
|
|
4af7836a54 | ||
|
|
cfe838dd07 | ||
|
|
8dafd5fe67 | ||
|
|
e932bee120 | ||
|
|
c1f6c19fdf | ||
|
|
b7ff3b07cd | ||
|
|
9bd69f7a07 | ||
|
|
357fb6c816 | ||
|
|
628cbc405e | ||
|
|
ada2f5e2a7 | ||
|
|
bdbdd27963 | ||
|
|
2272668ace | ||
|
|
bc554d3474 | ||
|
|
4d48100375 | ||
|
|
5cf5e34c4f | ||
|
|
36863d3c6a | ||
|
|
5a0188c635 | ||
|
|
d44fe916da | ||
|
|
2d166a204b | ||
|
|
b7ca20f7c3 | ||
|
|
ed9f693098 | ||
|
|
773e2c12b8 | ||
|
|
03ee4fb08e | ||
|
|
697cae9848 | ||
|
|
30d396896d | ||
|
|
fb68d59da4 | ||
|
|
6b43435097 | ||
|
|
a75347a59f | ||
|
|
7cd0c41ec5 | ||
|
|
c73b7ec252 | ||
|
|
f876bef7a3 | ||
|
|
b1ff138774 | ||
|
|
23a3c173dd | ||
|
|
528773128d | ||
|
|
311d47211e | ||
|
|
b6e4c32287 | ||
|
|
f485d87809 | ||
|
|
658aa0cae5 | ||
|
|
99294f26da | ||
|
|
d278a79030 | ||
|
|
ca84ac6bb5 | ||
|
|
2d3e070830 | ||
|
|
1f1e532233 | ||
|
|
93ae79ac7e | ||
|
|
bb8b345647 | ||
|
|
e9a6d45d1f | ||
|
|
49f9c3737a | ||
|
|
420989dc9f | ||
|
|
3a1d85dbe2 | ||
|
|
4ea12a1f79 | ||
|
|
898f2b14f2 | ||
|
|
7cabbafad5 | ||
|
|
eae69d4392 | ||
|
|
a8c027bd6e | ||
|
|
b7b0829c5b | ||
|
|
0410e8c52d | ||
|
|
fd42502d05 | ||
|
|
340990573f | ||
|
|
eca52f35cb | ||
|
|
14bd72756a | ||
|
|
7797549baa | ||
|
|
93c3bc612d | ||
|
|
b091a8d82a | ||
|
|
454b8bfb8d | ||
|
|
48898fcd09 | ||
|
|
0f233ce6e2 | ||
|
|
976f3126f2 | ||
|
|
2af99f2740 | ||
|
|
fb680a4c66 | ||
|
|
44cfd2ab81 | ||
|
|
3af16b4c29 | ||
|
|
3d37388173 | ||
|
|
92eb28e495 | ||
|
|
99d2a3d451 | ||
|
|
fdd38c74f0 | ||
|
|
8ee7c9c193 | ||
|
|
4e5eeed9a2 | ||
|
|
31223ffc64 | ||
|
|
28441eebf1 | ||
|
|
bf4e38509a | ||
|
|
ba690c1e03 | ||
|
|
b58c961da4 | ||
|
|
10a9167a1f | ||
|
|
ebe72c3ab0 | ||
|
|
859a8f1d64 | ||
|
|
b1eb3c46a8 | ||
|
|
a0e8e47fae | ||
|
|
521bd7ea93 | ||
|
|
3e7d702ab7 | ||
|
|
09bc64e771 | ||
|
|
cee3ee0581 | ||
|
|
4245d7a991 | ||
|
|
937e7b67e9 | ||
|
|
b23d939481 | ||
|
|
e9e929f577 | ||
|
|
f50bff4819 | ||
|
|
b3c5580538 | ||
|
|
1ec2c5cc14 | ||
|
|
a7d1e1e8df | ||
|
|
e46e23fada | ||
|
|
a9f6484fb0 | ||
|
|
1dfa1cc1ba |
@@ -6,6 +6,6 @@ NEXT_PUBLIC_USE_MOCK_BACKEND_LOOP_START=false
|
||||
NEXT_PUBLIC_EXPORT_STATIC=false
|
||||
NEXT_PUBLIC_USE_CGI=false
|
||||
# App-Versionsnummer
|
||||
NEXT_PUBLIC_APP_VERSION=1.6.512
|
||||
NEXT_PUBLIC_APP_VERSION=1.6.913
|
||||
NEXT_PUBLIC_CPL_MODE=json # json (Entwicklungsumgebung) oder jsSimulatedProd (CPL ->CGI-Interface-Simulator) oder production (CPL-> CGI-Interface Platzhalter)
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ NEXT_PUBLIC_CPL_API_PATH=/CPL
|
||||
NEXT_PUBLIC_EXPORT_STATIC=true
|
||||
NEXT_PUBLIC_USE_CGI=true
|
||||
# App-Versionsnummer
|
||||
NEXT_PUBLIC_APP_VERSION=1.6.512
|
||||
NEXT_PUBLIC_APP_VERSION=1.6.913
|
||||
NEXT_PUBLIC_CPL_MODE=production
|
||||
@@ -14,7 +14,32 @@
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es2021": true
|
||||
},
|
||||
"globals": {
|
||||
"JSX": "readonly",
|
||||
"NodeJS": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// deine Regeln hier
|
||||
"no-undef": "off",
|
||||
"no-unreachable": "error",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "warn",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"@next/next/no-img-element": "warn",
|
||||
"react/no-unescaped-entities": "warn",
|
||||
"no-irregular-whitespace": [
|
||||
"error",
|
||||
{
|
||||
"skipComments": true,
|
||||
"skipStrings": true,
|
||||
"skipTemplates": true,
|
||||
"skipRegExps": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -9,6 +9,15 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
playwright/report/
|
||||
playwright/test-results/
|
||||
playwright/.cache/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
@@ -34,3 +43,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Playwright
|
||||
node_modules/
|
||||
|
||||
58
.woodpecker.yml
Normal file
58
.woodpecker.yml
Normal file
@@ -0,0 +1,58 @@
|
||||
when:
|
||||
- event: push
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: clone
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
depth: 0
|
||||
lfs: true
|
||||
submodules: true
|
||||
|
||||
- name: verify-mocks
|
||||
image: mcr.microsoft.com/playwright:v1.54.2-jammy
|
||||
commands:
|
||||
- pwd
|
||||
- node -v && npm -v
|
||||
# Skip lifecycle scripts in CI to avoid running husky's prepare step
|
||||
- npm ci
|
||||
# Zeig mir, ob die Datei wirklich im Checkout liegt:
|
||||
- echo "=== git ls-files ==="
|
||||
- git ls-files | grep -i "^mocks/device-cgi-simulator/SERVICE/systemMockData.js" || true
|
||||
- echo "=== ls -la ==="
|
||||
- ls -la mocks/device-cgi-simulator/SERVICE || true
|
||||
- echo "=== file exists? ==="
|
||||
- test -f mocks/device-cgi-simulator/SERVICE/systemMockData.js && echo "FOUND" || (echo "MISSING" && exit 1)
|
||||
|
||||
- name: e2e-dev
|
||||
image: mcr.microsoft.com/playwright:v1.54.2-jammy
|
||||
environment:
|
||||
CI: "true"
|
||||
NODE_ENV: "production"
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
PORT: "3000"
|
||||
commands:
|
||||
- node -v && npm -v
|
||||
# Skip lifecycle scripts in CI to avoid running husky's prepare step (husky is a devDep)
|
||||
- env npm_config_production=false npm ci
|
||||
- npm run build
|
||||
# Start local static simulator in background
|
||||
- npm run server:sim &
|
||||
# Wait until simulator responds on port 3000 (no curl dependency)
|
||||
- node -e "const http=require('http');let n=120;function ping(){http.get('http://localhost:3000',res=>{console.log('Server is up');process.exit(0)}).on('error',()=>{if(n--<=0){console.error('Server did not start');process.exit(1)}setTimeout(ping,1000)});}ping();"
|
||||
- npx playwright test --project=chromium
|
||||
|
||||
- name: notify-success
|
||||
image: alpine/curl:latest
|
||||
when:
|
||||
status: success
|
||||
commands:
|
||||
- curl -d "Tests erfolgreich in woodpecker" https://ntfy.sh/OEOr8DNB0aT2mXWg231PeEEKwvuzt86qgM8ezQmgfcX9ZIlZ35
|
||||
|
||||
- name: notify-failure
|
||||
image: alpine/curl:latest
|
||||
when:
|
||||
status: failure
|
||||
commands:
|
||||
- curl -d "Tests fehlgeschlagen in woodpecker" https://ntfy.sh/OEOr8DNB0aT2mXWg231PeEEKwvuzt86qgM8ezQmgfcX9ZIlZ35
|
||||
3015
CHANGELOG.md
3015
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
BIN
Git 2.pptx
Normal file
BIN
Git 2.pptx
Normal file
Binary file not shown.
95
Jenkinsfile
vendored
Normal file
95
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
environment {
|
||||
CI = "true"
|
||||
NODE_ENV = "production"
|
||||
NEXT_TELEMETRY_DISABLED = "1"
|
||||
PORT = "3000"
|
||||
}
|
||||
|
||||
options {
|
||||
timestamps()
|
||||
}
|
||||
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
sh '''
|
||||
set -eux
|
||||
git status --short || true
|
||||
# Submodule & LFS falls vorhanden
|
||||
git submodule update --init --recursive || true
|
||||
git lfs install || true
|
||||
git lfs fetch || true
|
||||
git lfs checkout || true
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('verify-mocks') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eux
|
||||
docker run --rm -v "$PWD":/ws -w /ws \
|
||||
mcr.microsoft.com/playwright:v1.54.2-jammy bash -lc "
|
||||
pwd
|
||||
node -v && npm -v
|
||||
npm ci --ignore-scripts
|
||||
echo '=== git ls-files ==='
|
||||
git ls-files | grep -i '^mocks/device-cgi-simulator/SERVICE/systemMockData.js' || true
|
||||
echo '=== ls -la ==='
|
||||
ls -la mocks/device-cgi-simulator/SERVICE || true
|
||||
echo '=== file exists? ==='
|
||||
test -f mocks/device-cgi-simulator/SERVICE/systemMockData.js && echo 'FOUND' || (echo 'MISSING' && exit 1)
|
||||
"
|
||||
'''
|
||||
}
|
||||
}
|
||||
|
||||
stage('e2e-dev') {
|
||||
steps {
|
||||
sh '''
|
||||
set -eux
|
||||
docker run --rm -v "$PWD":/ws -w /ws -p 3000:3000 \
|
||||
mcr.microsoft.com/playwright:v1.54.2-jammy bash -lc "
|
||||
node -v && npm -v
|
||||
env npm_config_production=false npm ci
|
||||
npm run build
|
||||
npm run server:sim &
|
||||
# Auf Port 3000 warten
|
||||
node -e \\"const http=require('http');let n=120;function ping(){http.get('http://localhost:3000',res=>{console.log('Server is up');process.exit(0)}).on('error',()=>{if(n--<=0){console.error('Server did not start');process.exit(1)}setTimeout(ping,1000)});}ping();\\"
|
||||
npx playwright test --project=chromium
|
||||
"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
success {
|
||||
sh '''
|
||||
docker run --rm curlimages/curl:8.9.1 \
|
||||
-d "Tests erfolgreich in Jenkins" \
|
||||
https://ntfy.sh/OEOr8DNB0aT2mXWg231PeEEKwvuzt86qgM8ezQmgfcX9ZIlZ35
|
||||
'''
|
||||
}
|
||||
failure {
|
||||
sh '''
|
||||
docker run --rm curlimages/curl:8.9.1 \
|
||||
-d "Tests fehlgeschlagen in Jenkins" \
|
||||
https://ntfy.sh/OEOr8DNB0aT2mXWg231PeEEKwvuzt86qgM8ezQmgfcX9Z35
|
||||
'''
|
||||
}
|
||||
always {
|
||||
script {
|
||||
if (fileExists('playwright-report')) {
|
||||
archiveArtifacts artifacts: 'playwright-report/**', onlyIfSuccessful: false
|
||||
} else {
|
||||
echo 'Kein playwright-report gefunden.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,11 +168,11 @@ Beispielaufruf im DEV-Modus (über UI gesteuert, nicht manuell notwendig):
|
||||
### 🔌 System
|
||||
|
||||
- Live-Anzeige von:
|
||||
- +5V, +15V, -15V, -98V Spannungen
|
||||
- +5V, +15V, -15V, -96V Spannungen
|
||||
- CPU- und ADC-Temperaturen
|
||||
- Verlaufskurven über Zeit (Chart.js)
|
||||
- Spannungen und Temperaturen werden jetzt in zwei separaten Charts nebeneinander dargestellt
|
||||
- Spannungswerte (+5V, +15V, -15V, -98V) werden mit zwei Nachkommastellen angezeigt
|
||||
- Spannungswerte (+5V, +15V, -15V, -96V) werden mit zwei Nachkommastellen angezeigt
|
||||
|
||||
### ⚙️ Einstellungen
|
||||
|
||||
|
||||
43
components/common/ConfirmModal.tsx
Normal file
43
components/common/ConfirmModal.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// components/common/ConfirmModal.tsx
|
||||
import React from "react";
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
|
||||
<div className="bg-white p-6 rounded shadow-xl w-[360px] max-w-full text-center">
|
||||
{title && <h2 className="text-lg font-semibold mb-3">{title}</h2>}
|
||||
<p className="mb-6 text-gray-800">{message}</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
className="bg-gray-300 hover:bg-gray-400 text-black px-4 py-2 rounded"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,27 @@
|
||||
// /components/main/kabelueberwachung/kue705FO/Charts/LoopMeasurementChart/DateRangePicker.tsx
|
||||
// /components/common/DateRangePicker.tsx
|
||||
import React, { useEffect } from "react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import {
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { setVonDatum, setBisDatum } from "@/redux/slices/dateRangePickerSlice";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
const DateRangePicker: React.FC = () => {
|
||||
interface DateRangePickerProps {
|
||||
compact?: boolean; // reduziert horizontale Breite
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DateRangePicker: React.FC<DateRangePickerProps> = ({
|
||||
compact = false,
|
||||
className = "",
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const reduxVonDatum = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice.vonDatum
|
||||
(state: RootState) => state.dateRangePicker.vonDatum
|
||||
);
|
||||
const reduxBisDatum = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice.bisDatum
|
||||
(state: RootState) => state.dateRangePicker.bisDatum
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
@@ -41,10 +46,22 @@ const DateRangePicker: React.FC = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dispatch, reduxVonDatum, reduxBisDatum]);
|
||||
|
||||
const gapClass = compact ? "space-x-2" : "space-x-4";
|
||||
const labelWidthClass = compact ? "w-6" : "w-auto";
|
||||
const inputWidthClass = compact ? "w-32" : "w-44"; // ca. 128px vs 176px
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4 items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="block text-sm font-semibold">Von</label>
|
||||
<div className={`flex ${gapClass} items-center ${className}`}>
|
||||
<div
|
||||
className={`flex items-center space-x-1 ${compact ? "text-xs" : ""}`}
|
||||
>
|
||||
<label
|
||||
className={`block font-semibold ${
|
||||
compact ? "text-xs" : "text-sm"
|
||||
} ${labelWidthClass}`}
|
||||
>
|
||||
Von
|
||||
</label>
|
||||
<DatePicker
|
||||
selected={reduxVonDatum ? parseISODate(reduxVonDatum) : thirtyDaysAgo}
|
||||
onChange={(date) => {
|
||||
@@ -60,12 +77,23 @@ const DateRangePicker: React.FC = () => {
|
||||
minDate={sixMonthsAgo}
|
||||
maxDate={today}
|
||||
dateFormat="dd.MM.yyyy"
|
||||
className="border px-2 py-1 rounded"
|
||||
portalId="root-portal"
|
||||
popperClassName="custom-datepicker-popper"
|
||||
className={`border px-2 py-1 rounded ${inputWidthClass} ${
|
||||
compact ? "text-xs" : "text-sm"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="block text-sm font-semibold">Bis</label>
|
||||
<div
|
||||
className={`flex items-center space-x-1 ${compact ? "text-xs" : ""}`}
|
||||
>
|
||||
<label
|
||||
className={`block font-semibold ${
|
||||
compact ? "text-xs" : "text-sm"
|
||||
} ${labelWidthClass}`}
|
||||
>
|
||||
Bis
|
||||
</label>
|
||||
<DatePicker
|
||||
selected={reduxBisDatum ? parseISODate(reduxBisDatum) : today}
|
||||
onChange={(date) => {
|
||||
@@ -81,7 +109,11 @@ const DateRangePicker: React.FC = () => {
|
||||
minDate={sixMonthsAgo}
|
||||
maxDate={today}
|
||||
dateFormat="dd.MM.yyyy"
|
||||
className="border px-2 py-1 rounded"
|
||||
portalId="root-portal"
|
||||
popperClassName="custom-datepicker-popper"
|
||||
className={`border px-2 py-1 rounded ${inputWidthClass} ${
|
||||
compact ? "text-xs" : "text-sm"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
77
components/common/DeviceEventsBridge.tsx
Normal file
77
components/common/DeviceEventsBridge.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { useAppDispatch } from "@/redux/store";
|
||||
import {
|
||||
setEvents,
|
||||
initPersistedTimings,
|
||||
} from "@/redux/slices/deviceEventsSlice";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
loopMeasurementEvent?: number[];
|
||||
tdrMeasurementEvent?: number[];
|
||||
comparisonEvent?: number[]; // renamed from alignmentEvent
|
||||
}
|
||||
}
|
||||
|
||||
const POLL_MS = 2000; // poll every 2 seconds
|
||||
|
||||
export default function DeviceEventsBridge() {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
React.useEffect(() => {
|
||||
let lastSig = "";
|
||||
// Hydrate persisted timings once
|
||||
try {
|
||||
const raw =
|
||||
typeof window !== "undefined" &&
|
||||
localStorage.getItem("deviceEventsTimingsV1");
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
dispatch(
|
||||
initPersistedTimings({
|
||||
loop: parsed.loop,
|
||||
tdr: parsed.tdr,
|
||||
compare: parsed.compare || parsed.align,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("DeviceEventsBridge hydration failed", e);
|
||||
}
|
||||
const readAndDispatch = () => {
|
||||
const ksx = Array.isArray(window.loopMeasurementEvent)
|
||||
? window.loopMeasurementEvent
|
||||
: undefined;
|
||||
const ksy = Array.isArray(window.tdrMeasurementEvent)
|
||||
? window.tdrMeasurementEvent
|
||||
: undefined;
|
||||
const ksz = Array.isArray(window.comparisonEvent)
|
||||
? window.comparisonEvent
|
||||
: undefined;
|
||||
// Build a stable signature of first 32 values per array
|
||||
const to32 = (a?: number[]) => {
|
||||
const out: number[] = [];
|
||||
if (Array.isArray(a)) {
|
||||
for (let i = 0; i < 32; i++) out.push(a[i] ? 1 : 0);
|
||||
} else {
|
||||
for (let i = 0; i < 32; i++) out.push(0);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const sig = `${to32(ksx).join("")}|${to32(ksy).join("")}|${to32(ksz).join(
|
||||
""
|
||||
)}`;
|
||||
if (sig !== lastSig) {
|
||||
lastSig = sig;
|
||||
dispatch(setEvents({ ksx, ksy, ksz }));
|
||||
}
|
||||
};
|
||||
readAndDispatch();
|
||||
const id = setInterval(readAndDispatch, POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [dispatch]);
|
||||
|
||||
return null;
|
||||
}
|
||||
132
components/common/GlobalActivityOverlay.tsx
Normal file
132
components/common/GlobalActivityOverlay.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAppSelector } from "@/redux/store";
|
||||
|
||||
export default function GlobalActivityOverlay() {
|
||||
const anyLoop = useAppSelector((s) => s.deviceEvents.anyLoopActive);
|
||||
const anyTdr = useAppSelector((s) => s.deviceEvents.anyTdrActive);
|
||||
const anyCompare = useAppSelector((s) => s.deviceEvents.anyComparisonActive);
|
||||
const ksx = useAppSelector((s) => s.deviceEvents.ksx);
|
||||
const ksy = useAppSelector((s) => s.deviceEvents.ksy);
|
||||
const ksz = useAppSelector((s) => s.deviceEvents.ksz);
|
||||
const loopStartedAt = useAppSelector((s) => s.deviceEvents.loopStartedAt);
|
||||
const tdrStartedAt = useAppSelector((s) => s.deviceEvents.tdrStartedAt);
|
||||
const comparisonStartedAt = useAppSelector(
|
||||
(s) => s.deviceEvents.comparisonStartedAt
|
||||
);
|
||||
|
||||
const fmt = (arr: number[]) =>
|
||||
arr
|
||||
.map((v, i) => (v ? i + 1 : 0))
|
||||
.filter((n) => n !== 0)
|
||||
.join(", ");
|
||||
|
||||
// Simple 1s ticker so progress bars advance while overlay is shown
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
useEffect(() => {
|
||||
const active = anyLoop || anyTdr || anyCompare;
|
||||
if (!active) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [anyLoop, anyTdr, anyCompare]);
|
||||
|
||||
const active = anyLoop || anyTdr || anyCompare;
|
||||
if (!active) return null;
|
||||
|
||||
const clamp = (v: number, min = 0, max = 1) =>
|
||||
Math.max(min, Math.min(max, v));
|
||||
|
||||
const compute = (startedAt: number | null, durationMs: number) => {
|
||||
if (!startedAt) return { pct: 0, remaining: durationMs };
|
||||
const elapsed = now - startedAt;
|
||||
const pct = clamp(elapsed / durationMs) * 100;
|
||||
const remaining = Math.max(0, durationMs - Math.max(0, elapsed));
|
||||
return { pct, remaining };
|
||||
};
|
||||
|
||||
// Durations
|
||||
const LOOP_MS = 2 * 60 * 1000; // ~2 min
|
||||
const TDR_MS = 30 * 1000; // ~30 s
|
||||
const ALIGN_MS = 10 * 60 * 1000; // ~10 min
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] flex items-center justify-center bg-white/70 backdrop-blur-sm">
|
||||
<div className="p-4 rounded-md shadow bg-white border border-gray-200 w-[min(90vw,680px)]">
|
||||
<div className="font-semibold mb-3">Bitte warten…</div>
|
||||
<div className="space-y-3">
|
||||
{anyLoop && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-800 mb-1">
|
||||
Schleifenmessung läuft… (KÜ: {fmt(ksx)})
|
||||
</div>
|
||||
{(() => {
|
||||
const { pct } = compute(loopStartedAt, LOOP_MS);
|
||||
return (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anyTdr && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-800 mb-1">
|
||||
TDR-Messung läuft… (KÜ: {fmt(ksy)})
|
||||
</div>
|
||||
{(() => {
|
||||
const { pct } = compute(tdrStartedAt, TDR_MS);
|
||||
return (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anyCompare && (
|
||||
<div>
|
||||
<div className="text-sm text-gray-800 mb-1">
|
||||
Comparison läuft… (KÜ: {fmt(ksz)}) kann bis zu 10 Minuten dauern
|
||||
</div>
|
||||
{(() => {
|
||||
const { pct } = compute(comparisonStartedAt, ALIGN_MS);
|
||||
return (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,45 +55,57 @@ function Footer() {
|
||||
}, [showSlider]);
|
||||
|
||||
return (
|
||||
<footer className="relative bg-gray-300 p-4 xl:p-2 2xl:p-4 overflow-hidden text-black laptop:h-[5vh] ">
|
||||
<footer className="relative bg-[var(--color-surface-alt)] border-t border-base p-4 xl:p-2 2xl:p-4 overflow-hidden text-[var(--color-fg)] laptop:h-[5vh] theme-transition">
|
||||
<div className="container mx-auto flex justify-between">
|
||||
<div className="flex flex-row space-x-2">
|
||||
<div className="flex flex-row space-x-2 items-center">
|
||||
<Icon
|
||||
icon="material-symbols:factory-outline"
|
||||
className="text-xl text-blue-400"
|
||||
className="text-xl text-accent"
|
||||
/>
|
||||
<p className="text-sm">Littwin Systemtechnik GmbH & Co. KG</p>
|
||||
<p className="text-sm text-fg-muted">
|
||||
Littwin Systemtechnik GmbH & Co. KG
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Icon icon="charm:phone" className="text-xl text-blue-400" />
|
||||
<p className="text-sm">Telefon: 04402 972577-0</p>
|
||||
<div className="flex flex-row space-x-2 items-center">
|
||||
<Icon icon="charm:phone" className="text-xl text-accent" />
|
||||
<p className="text-sm text-fg-muted">Telefon: 04402 972577-0</p>
|
||||
</div>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<Icon icon="mdi:email-outline" className="text-xl text-blue-400" />
|
||||
<p className="text-sm">kontakt@littwin-systemtechnik.de</p>
|
||||
<div className="flex flex-row space-x-2 items-center">
|
||||
<Icon icon="mdi:email-outline" className="text-xl text-accent" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
kontakt@littwin-systemtechnik.de
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-row space-x-2 cursor-pointer"
|
||||
className="flex flex-row space-x-2 cursor-pointer items-center group"
|
||||
onClick={() => setShowSlider(true)}
|
||||
>
|
||||
<Icon icon="bi:book" className="text-xl text-blue-400" />
|
||||
<p className="text-sm">Handbücher</p>
|
||||
<Icon
|
||||
icon="bi:book"
|
||||
className="text-xl text-accent group-hover:brightness-110 transition"
|
||||
/>
|
||||
<p className="text-sm text-fg-muted group-hover:text-[var(--color-fg)] transition">
|
||||
Handbücher
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={sliderRef}
|
||||
className={`fixed top-0 right-0 w-64 h-full bg-white shadow-lg transform transition-transform duration-300 ${
|
||||
className={`fixed top-0 right-0 w-64 h-full bg-[var(--color-surface)] border-l border-base shadow-lg transform transition-transform duration-300 ${
|
||||
showSlider ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 flex justify-between items-center border-b">
|
||||
<h3 className="text-lg font-semibold">PDF Handbücher</h3>
|
||||
<div className="p-4 flex justify-between items-center border-b border-base">
|
||||
<h3 className="text-base font-semibold text-[var(--color-fg)]">
|
||||
PDF Handbücher
|
||||
</h3>
|
||||
<button
|
||||
className="text-gray-500 hover:text-gray-800"
|
||||
className="text-[var(--color-muted)] hover:text-[var(--color-fg)] transition"
|
||||
onClick={() => setShowSlider(false)}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<Icon icon="carbon:close" className="text-2xl" />
|
||||
<Icon icon="carbon:close" className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -102,7 +114,7 @@ function Footer() {
|
||||
{pdfFiles.map((fileName) => (
|
||||
<li
|
||||
key={fileName}
|
||||
className="cursor-pointer text-blue-500 hover:underline mb-2"
|
||||
className="cursor-pointer text-accent hover:underline mb-2 text-sm"
|
||||
onClick={() => loadPDF(fileName)}
|
||||
>
|
||||
{fileName}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client"; // components/Header.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
@@ -14,16 +15,18 @@ function Header() {
|
||||
const router = useRouter();
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
||||
const [isAdminLoggedIn, setIsAdminLoggedIn] = useState(false);
|
||||
const autoLogoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Removed duplicate declaration of deviceName
|
||||
|
||||
const handleCloseSettingsModal = () => setShowSettingsModal(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
const handleLogout = useCallback(() => {
|
||||
sessionStorage.removeItem("token"); // Token entfernen
|
||||
localStorage.setItem("isAdminLoggedIn", "false"); // Admin-Status entfernen
|
||||
localStorage.removeItem("adminLoginTime"); // Login-Zeitpunkt entfernen
|
||||
setIsAdminLoggedIn(false); // Zustand sofort aktualisieren
|
||||
router.push("/offline.html"); // Weiterleitung
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialer Check beim Laden der Komponente
|
||||
@@ -42,6 +45,56 @@ function Header() {
|
||||
clearInterval(interval); // Intervall stoppen, wenn die Komponente entladen wird
|
||||
};
|
||||
}, [isAdminLoggedIn]);
|
||||
|
||||
// Auto-Logout nach 1 Minute (Test): nutzt adminLoginTime aus localStorage
|
||||
useEffect(() => {
|
||||
// Timer bereinigen, wenn sich der Status ändert
|
||||
if (autoLogoutTimerRef.current) {
|
||||
clearTimeout(autoLogoutTimerRef.current);
|
||||
autoLogoutTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (!isAdminLoggedIn) return;
|
||||
|
||||
const iso = localStorage.getItem("adminLoginTime");
|
||||
const loginTime = iso ? new Date(iso).getTime() : Date.now();
|
||||
if (!iso) {
|
||||
// Falls älterer Login ohne Zeitstempel, setze jetzt
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"adminLoginTime",
|
||||
new Date(loginTime).toISOString()
|
||||
);
|
||||
} catch {
|
||||
void 0; // ignore write errors (e.g., storage disabled)
|
||||
}
|
||||
}
|
||||
|
||||
// 1 Minute ab Login (60_000 ms), eine Stunde (3_600_000 ms) im Produktivbetrieb
|
||||
const target = loginTime + 3_600_000;
|
||||
const delay = Math.max(0, target - Date.now());
|
||||
|
||||
// Fallback: wenn Datum in Vergangenheit (z.B. Uhrzeit geändert), sofort abmelden
|
||||
autoLogoutTimerRef.current = setTimeout(() => {
|
||||
// Versuche den Button zu klicken, falls vorhanden
|
||||
const btn = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Abmelden"]'
|
||||
);
|
||||
if (btn) {
|
||||
btn.click();
|
||||
} else {
|
||||
// Fallback direkt
|
||||
handleLogout();
|
||||
}
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (autoLogoutTimerRef.current) {
|
||||
clearTimeout(autoLogoutTimerRef.current);
|
||||
autoLogoutTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isAdminLoggedIn, handleLogout]);
|
||||
//----------------------------------------------------------------
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
@@ -56,14 +109,49 @@ function Header() {
|
||||
}, [deviceName, dispatch]);
|
||||
//----------------------------------------------------------------
|
||||
|
||||
// Dark/Light Mode Toggle (persisted)
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
// Initial state from html class / localStorage (set by _document script before hydration)
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const html = document.documentElement;
|
||||
const stored = localStorage.getItem("theme");
|
||||
const active = stored ? stored === "dark" : html.classList.contains("dark");
|
||||
setIsDark(active);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const html = document.documentElement;
|
||||
if (isDark) {
|
||||
html.classList.add("dark");
|
||||
localStorage.setItem("theme", "dark");
|
||||
} else {
|
||||
html.classList.remove("dark");
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
}, [isDark]);
|
||||
|
||||
// Keyboard shortcut Alt + D to toggle theme
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.altKey && (e.key === "d" || e.key === "D")) {
|
||||
e.preventDefault();
|
||||
setIsDark((d) => !d);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header className="bg-gray-300 flex justify-between items-center w-full h-[13vh] laptop:h-[10vh] relative text-black ">
|
||||
<header className="bg-[var(--color-surface-alt)] dark:bg-[var(--color-surface-alt)] flex justify-between items-center w-full h-[13vh] laptop:h-[10vh] relative text-[var(--color-fg)] border-b border-base theme-transition">
|
||||
<div
|
||||
className="absolute transform -translate-y-1/2
|
||||
left-[8%] sm:left-[8%] md:left-[8%] lg:left-[8%] xl:left-[6%] 2xl:left-[2%] laptop:left-[4%] laptop:
|
||||
top-[90%] sm:top-[90%] md:top-[90%] lg:top-[90%] xl:top-[90%]"
|
||||
style={{
|
||||
height: "10vh", // Dynamische Höhe des Containers
|
||||
height: "12vh", // Erhöhte Höhe des Containers für größeres Logo
|
||||
width: "auto",
|
||||
aspectRatio: "1", // Beibehaltung des Seitenverhältnisses
|
||||
}}
|
||||
@@ -72,7 +160,7 @@ function Header() {
|
||||
src="/images/Logo.png"
|
||||
alt="Logo"
|
||||
fill
|
||||
sizes="(max-width: 640px) 7vh, (max-width: 1024px) 8vh, (max-width: 1280px) 9vh, 10vh"
|
||||
sizes="(max-width: 640px) 12vh, (max-width: 1024px) 14vh, (max-width: 1280px) 16vh, 18vh"
|
||||
className="object-contain"
|
||||
priority={false}
|
||||
/>
|
||||
@@ -88,43 +176,55 @@ function Header() {
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col leading-tight whitespace-nowrap">
|
||||
<h2 className="text-xl laptop:text-base xl:text-lg font-bold">
|
||||
<h2 className="text-xl laptop:text-base xl:text-lg font-bold text-[var(--color-fg)]">
|
||||
Meldestation
|
||||
</h2>
|
||||
<p className="text-gray-600 text-lg laptop:text-sm xl:text-base truncate max-w-[20vw]">
|
||||
<p className="text-[var(--color-fg-muted)] text-lg laptop:text-sm xl:text-base truncate max-w-[20vw]">
|
||||
{deviceName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 w-full lg:w-full flex flex-row gap-4 justify-between">
|
||||
<div className="flex items-center justify-end w-full">
|
||||
{/* Admin-Login */}
|
||||
{/*
|
||||
<div className="flex items-center justify-end w-full gap-4">
|
||||
{/* Dark/Light Mode Toggle */}
|
||||
<button
|
||||
onClick={handleSettingsClick}
|
||||
className="text-3xl text-black mr-0"
|
||||
aria-label={isDark ? "Light Mode" : "Dark Mode"}
|
||||
onClick={() => setIsDark((d) => !d)}
|
||||
className="rounded-full p-2 bg-[var(--color-surface-alt)]/80 hover:bg-[var(--color-surface-alt)] dark:bg-[var(--color-surface-alt)]/60 dark:hover:bg-[var(--color-surface-alt)] transition border border-[var(--color-border)]"
|
||||
title={isDark ? "Light Mode" : "Dark Mode"}
|
||||
>
|
||||
<i className="bi bi-gear"></i>
|
||||
</button>
|
||||
*/}
|
||||
</div>
|
||||
|
||||
{/* Logout-Button */}
|
||||
|
||||
<div className="flex items-center justify-end w-1/4 space-x-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded"
|
||||
>
|
||||
Abmelden
|
||||
{isDark ? (
|
||||
<Icon
|
||||
icon="mdi:weather-night"
|
||||
className="text-xl text-yellow-300"
|
||||
/>
|
||||
) : (
|
||||
<Icon
|
||||
icon="mdi:white-balance-sunny"
|
||||
className="text-xl text-yellow-500"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout-Button - nur anzeigen wenn Admin eingeloggt ist */}
|
||||
{isAdminLoggedIn && (
|
||||
<div className="flex items-center justify-end w-1/4 space-x-1">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
aria-label="Abmelden"
|
||||
className="px-4 py-2 rounded bg-[var(--color-accent)] text-white hover:brightness-110 shadow-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-ring)] focus:ring-offset-2 focus:ring-offset-[var(--color-background)] transition"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnhinweis, wenn der Admin angemeldet ist */}
|
||||
{isAdminLoggedIn && (
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-full xl:w-1/4 2xl:w-1/4 h-8 bg-yellow-400 text-center py-2 text-black font-bold">
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-full xl:w-1/4 2xl:w-1/4 h-8 bg-[var(--color-warning)] text-center py-2 text-black font-bold tracking-wide">
|
||||
Admin-Modus aktiv
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ const handleClearDatabase = async () => {
|
||||
}
|
||||
|
||||
// Full URL with host, current path, and clear database command
|
||||
const url = `${window.location.origin}/CPL?${currentPath}&DEDB=1`;
|
||||
const url = `${window.location.origin}/CPL?/${window.location.pathname}&DEDB=1`;
|
||||
|
||||
// Log the full URL to the console for debugging
|
||||
console.log(url);
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"use client";
|
||||
type AnalogInput = {
|
||||
id: number;
|
||||
label: string;
|
||||
unit: string;
|
||||
};
|
||||
import React, { useEffect } from "react";
|
||||
// components/main/analogInputs/AnalogInputsChart.tsx
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState, AppDispatch } from "@/redux/store";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { getColor } from "@/utils/colors";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LineElement,
|
||||
@@ -17,14 +15,23 @@ import {
|
||||
Legend,
|
||||
Filler,
|
||||
TimeScale,
|
||||
TooltipItem,
|
||||
} from "chart.js";
|
||||
import "chartjs-adapter-date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import type { RootState, AppDispatch } from "../../../redux/store";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import { getAnalogInputsHistoryThunk } from "@/redux/thunks/getAnalogInputsHistoryThunk";
|
||||
import {
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
setZeitraum,
|
||||
setAutoLoad,
|
||||
} from "@/redux/slices/analogInputs/analogInputsHistorySlice";
|
||||
import { getColor } from "@/utils/colors";
|
||||
import AnalogInputsDatePicker from "./AnalogInputsDatePicker";
|
||||
import type { ChartJSOrUndefined } from "react-chartjs-2/dist/types";
|
||||
|
||||
// Basis-Registrierung (ohne Zoom-Plugin)
|
||||
// ✅ Nur die Basis-ChartJS-Module registrieren
|
||||
ChartJS.register(
|
||||
LineElement,
|
||||
PointElement,
|
||||
@@ -37,148 +44,402 @@ ChartJS.register(
|
||||
);
|
||||
|
||||
export default function AnalogInputsChart({
|
||||
selectedId,
|
||||
setLoading,
|
||||
loading,
|
||||
}: {
|
||||
selectedId: number | null;
|
||||
setLoading: (loading: boolean) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const selectedInput = useSelector(
|
||||
(state: RootState) => state.selectedAnalogInput
|
||||
) as unknown as AnalogInput | null;
|
||||
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
type AnalogInputHistoryPoint = { t: string | number | Date; m: number };
|
||||
|
||||
const { data } = useSelector(
|
||||
(state: RootState) => state.analogInputsHistory
|
||||
) as {
|
||||
data: { [key: string]: AnalogInputHistoryPoint[] };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getAnalogInputsHistoryThunk());
|
||||
}, [dispatch]);
|
||||
|
||||
// ✅ Zoom-Plugin dynamisch importieren und registrieren
|
||||
useEffect(() => {
|
||||
const loadZoomPlugin = async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const zoomPlugin = (await import("chartjs-plugin-zoom")).default;
|
||||
if (typeof window !== "undefined") {
|
||||
import("chartjs-plugin-zoom").then((zoom) => {
|
||||
if (!ChartJS.registry.plugins.get("zoom")) {
|
||||
ChartJS.register(zoomPlugin);
|
||||
ChartJS.register(zoom.default);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadZoomPlugin();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!selectedId) {
|
||||
return (
|
||||
<div className="text-gray-500">Bitte einen Messwerteingang auswählen</div>
|
||||
);
|
||||
}
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const key = String(selectedId + 99);
|
||||
const inputData = data[key];
|
||||
const chartRef =
|
||||
useRef<
|
||||
ChartJSOrUndefined<"line", { x: Date; y: number | undefined }[], unknown>
|
||||
>(null);
|
||||
|
||||
if (!inputData) {
|
||||
return (
|
||||
<div className="text-red-500">
|
||||
Keine Verlaufsdaten für Messwerteingang {selectedId} gefunden.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Redux Werte für Chart-Daten
|
||||
const { zeitraum, vonDatum, bisDatum, data, autoLoad, selectedId } =
|
||||
useSelector((state: RootState) => state.analogInputsHistory);
|
||||
const selectedAnalogInput = useSelector(
|
||||
(state: RootState) => state.selectedAnalogInput
|
||||
);
|
||||
|
||||
const chartData = {
|
||||
datasets: [
|
||||
{
|
||||
label: `Messkurve ${selectedInput?.label ?? "Eingang"} [${
|
||||
selectedInput?.unit ?? ""
|
||||
}]`,
|
||||
data: inputData.map((point: AnalogInputHistoryPoint) => ({
|
||||
x: point.t,
|
||||
y: point.m,
|
||||
})),
|
||||
fill: false,
|
||||
borderColor: getColor("littwin-blue"),
|
||||
backgroundColor: "rgba(59,130,246,0.5)",
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
},
|
||||
],
|
||||
// Redux initiale Datum-Werte
|
||||
const vonDatumRedux = useSelector(
|
||||
(state: RootState) => state.dateRangePicker.vonDatum
|
||||
);
|
||||
const bisDatumRedux = useSelector(
|
||||
(state: RootState) => state.dateRangePicker.bisDatum
|
||||
);
|
||||
|
||||
// Hilfsfunktion für Default-Datum
|
||||
const getDefaultDate = (type: "from" | "to") => {
|
||||
const today = new Date();
|
||||
if (type === "to") return today.toISOString().slice(0, 10);
|
||||
const fromDateObj = new Date(today);
|
||||
fromDateObj.setDate(today.getDate() - 30);
|
||||
return fromDateObj.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: { position: "top" as const },
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function (context: import("chart.js").TooltipItem<"line">) {
|
||||
const y = context.parsed.y;
|
||||
return `Messwert: ${y}`;
|
||||
},
|
||||
title: function (
|
||||
tooltipItems: import("chart.js").TooltipItem<"line">[]
|
||||
) {
|
||||
const date = tooltipItems[0].parsed.x;
|
||||
return `Zeitpunkt: ${new Date(date).toLocaleString("de-DE")}`;
|
||||
// ✅ Lokale States für Picker + Zeitraum
|
||||
const [localVonDatum, setLocalVonDatum] = React.useState(
|
||||
vonDatumRedux || getDefaultDate("from")
|
||||
);
|
||||
const [localBisDatum, setLocalBisDatum] = React.useState(
|
||||
bisDatumRedux || getDefaultDate("to")
|
||||
);
|
||||
const [localZeitraum, setLocalZeitraum] = React.useState(zeitraum);
|
||||
|
||||
// Synchronisiere lokale Werte mit Redux (z.B. nach AutoLoad Reset)
|
||||
useEffect(() => {
|
||||
setLocalVonDatum(vonDatumRedux || getDefaultDate("from"));
|
||||
setLocalBisDatum(bisDatumRedux || getDefaultDate("to"));
|
||||
setLocalZeitraum(zeitraum);
|
||||
}, [vonDatumRedux, bisDatumRedux, zeitraum]);
|
||||
|
||||
// Initiale Default-Werte: 30 Tage zurück (nur wenn Redux-Werte fehlen)
|
||||
useEffect(() => {
|
||||
if (!vonDatumRedux || !bisDatumRedux) {
|
||||
const today = new Date();
|
||||
const toDate = today.toISOString().slice(0, 10);
|
||||
const fromDateObj = new Date(today);
|
||||
fromDateObj.setDate(today.getDate() - 30);
|
||||
const fromDate = fromDateObj.toISOString().slice(0, 10);
|
||||
setLocalVonDatum(fromDate);
|
||||
setLocalBisDatum(toDate);
|
||||
}
|
||||
}, [vonDatumRedux, bisDatumRedux]);
|
||||
|
||||
// ✅ Nur lokale Änderung beim Picker
|
||||
const handleDateChange = (from: string, to: string) => {
|
||||
setLocalVonDatum(from);
|
||||
setLocalBisDatum(to);
|
||||
};
|
||||
|
||||
// ✅ Button → Redux + Fetch triggern
|
||||
const handleFetchData = () => {
|
||||
if (!selectedAnalogInput?.id) return;
|
||||
|
||||
setLoading(true); // Set loading to true when fetching data
|
||||
|
||||
// Fallback auf Redux-Werte, falls lokale Werte leer sind
|
||||
const from = localVonDatum || vonDatumRedux || "";
|
||||
const to = localBisDatum || bisDatumRedux || "";
|
||||
|
||||
// Redux aktualisieren
|
||||
dispatch(setVonDatum(from));
|
||||
dispatch(setBisDatum(to));
|
||||
dispatch(setZeitraum(localZeitraum));
|
||||
|
||||
// Umgebung erkennen und URL generieren
|
||||
const isDev =
|
||||
window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1";
|
||||
let fetchUrl = "";
|
||||
if (isDev) {
|
||||
fetchUrl = `/api/cpl/getAnalogInputsHistory?eingang=${selectedAnalogInput.id}&zeitraum=${localZeitraum}&von=${from}&bis=${to}`;
|
||||
} else {
|
||||
// Produktion: CPL-Webserver direkt abfragen
|
||||
const [vonJahr, vonMonat, vonTag] = from.split("-");
|
||||
const [bisJahr, bisMonat, bisTag] = to.split("-");
|
||||
const aeEingang = 100 + (selectedAnalogInput.id - 1);
|
||||
let diaType = "DIA1";
|
||||
if (localZeitraum === "DIA0") diaType = "DIA0";
|
||||
if (localZeitraum === "DIA2") diaType = "DIA2";
|
||||
fetchUrl = `${window.location.origin}/CPL?seite.ACP&${diaType}=${vonJahr};${vonMonat};${vonTag};${bisJahr};${bisMonat};${bisTag};${aeEingang};1`;
|
||||
}
|
||||
console.log("Fetch-URL:", fetchUrl);
|
||||
|
||||
// Thunk-Fetch mit neuen Werten
|
||||
dispatch(
|
||||
getAnalogInputsHistoryThunk({
|
||||
eingang: selectedAnalogInput.id,
|
||||
zeitraum: localZeitraum,
|
||||
vonDatum: from,
|
||||
bisDatum: to,
|
||||
})
|
||||
).finally(() => setLoading(false)); // Reset loading after fetch
|
||||
};
|
||||
|
||||
// Auto-trigger fetch when a row is selected and id is not 0 (only once per selection)
|
||||
React.useEffect(() => {
|
||||
if (selectedAnalogInput?.id && selectedAnalogInput.id !== 0) {
|
||||
handleFetchData();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedAnalogInput?.id]);
|
||||
|
||||
// ✅ Chart-Daten aus Redux filtern (Chart reagiert nur nach Button)
|
||||
const chartKey = selectedAnalogInput?.id
|
||||
? String(selectedAnalogInput.id + 99)
|
||||
: null;
|
||||
const inputData = chartKey ? data[chartKey] ?? [] : [];
|
||||
|
||||
const filteredData = inputData.filter((point) => {
|
||||
const date = new Date(point.t);
|
||||
const from = vonDatumRedux ? new Date(vonDatumRedux) : null;
|
||||
const to = bisDatumRedux ? new Date(bisDatumRedux) : null;
|
||||
return (!from || date >= from) && (!to || date <= to);
|
||||
});
|
||||
|
||||
const memoizedChartData = React.useMemo(() => {
|
||||
return {
|
||||
datasets:
|
||||
filteredData.length > 0
|
||||
? zeitraum === "DIA0"
|
||||
? [
|
||||
{
|
||||
label: "Messwert Minimum ", // (i)
|
||||
data: filteredData
|
||||
.filter((p) => typeof p.i === "number")
|
||||
.map((p) => ({ x: new Date(p.t), y: p.i })),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
label: selectedAnalogInput?.label
|
||||
? //? `Messwert ${selectedAnalogInput.label}` // (m)
|
||||
`Messwert ` // (m)
|
||||
: "Messwert ", // (m)
|
||||
data: filteredData
|
||||
.filter((p) => typeof p.m === "number")
|
||||
.map((p) => ({ x: new Date(p.t), y: p.m })),
|
||||
borderColor: getColor("littwin-blue"),
|
||||
backgroundColor: "rgba(59,130,246,0.3)",
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
label: "Messwert Maximum ", // (a)
|
||||
data: filteredData
|
||||
.filter((p) => typeof p.a === "number")
|
||||
.map((p) => ({ x: new Date(p.t), y: p.a })),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 3,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: "Messwert Minimum", // (i)
|
||||
data: filteredData
|
||||
.filter((p) => typeof p.i === "number")
|
||||
.map((p) => ({ x: new Date(p.t), y: p.i })),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
label: "Durchschnitt", // (g)
|
||||
data: filteredData
|
||||
.filter((p) => typeof p.g === "number")
|
||||
.map((p) => ({ x: new Date(p.t), y: p.g })),
|
||||
borderColor: getColor("littwin-blue"),
|
||||
backgroundColor: "rgba(59,130,246,0.3)",
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
label: "Messwert Maximum", // (a)
|
||||
data: filteredData
|
||||
.filter((p) => typeof p.a === "number")
|
||||
.map((p) => ({ x: new Date(p.t), y: p.a })),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 3,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
}, [filteredData, zeitraum, selectedAnalogInput]);
|
||||
|
||||
const memoizedChartOptions = React.useMemo(() => {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: "top" as const },
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: (context: TooltipItem<"line">) => {
|
||||
const label = context.dataset.label || "";
|
||||
return `${label}: ${context.parsed.y}`;
|
||||
},
|
||||
title: (items: TooltipItem<"line">[]) => {
|
||||
const date = items[0].parsed.x;
|
||||
return `Zeitpunkt: ${new Date(date).toLocaleString("de-DE")}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
title: {
|
||||
display: true,
|
||||
text: `Verlauf der letzten 30 Tage`,
|
||||
},
|
||||
zoom: {
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: "x" as const,
|
||||
title: {
|
||||
display: true,
|
||||
text: selectedAnalogInput?.label
|
||||
? `Verlauf: ${selectedAnalogInput.label}`
|
||||
: "Messwert-Verlauf",
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: "x" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time" as const,
|
||||
time: {
|
||||
unit: "day" as const, // nur Datum in Achse
|
||||
tooltipFormat: "dd.MM.yyyy HH:mm", // aber Uhrzeit im Tooltip sichtbar
|
||||
displayFormats: {
|
||||
day: "dd.MM.yyyy",
|
||||
pan: {
|
||||
enabled: true,
|
||||
mode: "x" as const,
|
||||
},
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: "x" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time" as const,
|
||||
time: {
|
||||
unit: "day" as const,
|
||||
tooltipFormat: "dd.MM.yyyy HH:mm",
|
||||
displayFormats: {
|
||||
day: "dd.MM.yyyy",
|
||||
},
|
||||
},
|
||||
adapters: { date: { locale: de } },
|
||||
title: { display: true, text: "Zeit" },
|
||||
min: vonDatum ? new Date(vonDatum).getTime() : undefined,
|
||||
max: bisDatum ? new Date(bisDatum).getTime() : undefined,
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: `Messwert ${selectedAnalogInput?.unit || ""}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}, [vonDatum, bisDatum, selectedAnalogInput]);
|
||||
|
||||
adapters: {
|
||||
date: {
|
||||
locale: de,
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "Zeit",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: `Messwert [${selectedInput?.unit ?? ""}]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
// ✅ AutoLoad nur beim ersten Laden
|
||||
useEffect(() => {
|
||||
if (autoLoad && selectedId) {
|
||||
dispatch(
|
||||
getAnalogInputsHistoryThunk({
|
||||
eingang: selectedId,
|
||||
zeitraum,
|
||||
vonDatum,
|
||||
bisDatum,
|
||||
})
|
||||
);
|
||||
dispatch(setAutoLoad(false));
|
||||
}
|
||||
}, [autoLoad, selectedId, dispatch, zeitraum, vonDatum, bisDatum]);
|
||||
|
||||
// Dynamisches Importieren von chartjs-plugin-zoom nur im Browser
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
import("chartjs-plugin-zoom").then((module) => {
|
||||
ChartJS.register(module.default);
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<Line data={chartData} options={chartOptions} />
|
||||
<div
|
||||
className={`flex flex-col gap-2 h-full ${loading ? "cursor-wait" : ""}`}
|
||||
>
|
||||
<div className="flex justify-between items-center p-2 rounded-lg space-x-2 bg-[var(--color-surface-alt)] border border-base">
|
||||
<div className="flex justify-start">
|
||||
<Dialog.Title className="text-lg font-semibold text-fg">
|
||||
Eingang {selectedId ?? "–"}
|
||||
</Dialog.Title>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<div className="flex flex-wrap items-center gap-4 mb-2">
|
||||
{/* ✅ Neuer DatePicker mit schönem Styling (lokal, ohne Redux) */}
|
||||
<AnalogInputsDatePicker
|
||||
from={localVonDatum}
|
||||
to={localBisDatum}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
|
||||
{/* ✅ Zeitraum-Auswahl (Listbox nur lokal) */}
|
||||
<Listbox value={localZeitraum} onChange={setLocalZeitraum}>
|
||||
<div className="relative w-48">
|
||||
<Listbox.Button className="w-full border border-base px-3 py-1 rounded bg-[var(--color-surface)] text-fg flex justify-between items-center text-sm">
|
||||
<span>
|
||||
{localZeitraum === "DIA0"
|
||||
? "Alle Messwerte"
|
||||
: localZeitraum === "DIA1"
|
||||
? "Stündlich"
|
||||
: "Täglich"}
|
||||
</span>
|
||||
<i className="bi bi-chevron-down text-[var(--color-muted)]" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="absolute z-10 mt-1 w-full border border-base bg-[var(--color-surface)] shadow rounded text-sm">
|
||||
{["DIA0", "DIA1", "DIA2"].map((option) => (
|
||||
<Listbox.Option
|
||||
key={option}
|
||||
value={option}
|
||||
className="px-4 py-1 cursor-pointer hover:bg-[var(--color-surface-alt)] text-fg"
|
||||
>
|
||||
{option === "DIA0"
|
||||
? "Alle Messwerte"
|
||||
: option === "DIA1"
|
||||
? "Stündlich"
|
||||
: "Täglich"}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
{/* ✅ Button: lädt die Daten & aktualisiert Redux */}
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
className="btn-primary px-4 py-1 rounded text-sm"
|
||||
>
|
||||
Daten laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart-Anzeige */}
|
||||
<div className="flex-1 min-h-0 w-full">
|
||||
{!selectedAnalogInput?.id ? (
|
||||
<div className="flex items-center justify-center h-full text-fg-secondary text-lg gap-2">
|
||||
<i className="bi bi-info-circle text-2xl mr-2" />
|
||||
<span>Bitte Eingang auswählen</span>
|
||||
</div>
|
||||
) : (
|
||||
<Line
|
||||
ref={chartRef}
|
||||
data={memoizedChartData}
|
||||
options={memoizedChartOptions}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
88
components/main/analogInputs/AnalogInputsChartModal.tsx
Normal file
88
components/main/analogInputs/AnalogInputsChartModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Dialog } from "@headlessui/react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import { setIsChartModalOpen } from "@/redux/slices/analogInputs/analogInputsUiSlice";
|
||||
import AnalogInputsChart from "@/components/main/analogInputs/AnalogInputsChart";
|
||||
|
||||
export default function AnalogInputsChartModal({
|
||||
loading,
|
||||
setLoading,
|
||||
}: {
|
||||
loading: boolean;
|
||||
setLoading: (v: boolean) => void;
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const isOpen = useSelector(
|
||||
(state: RootState) => state.analogInputsUi.isChartModalOpen
|
||||
);
|
||||
const selectedId = useSelector(
|
||||
(state: RootState) => state.analogInputsHistory.selectedId
|
||||
);
|
||||
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={() => dispatch(setIsChartModalOpen(false))}
|
||||
className="relative z-[9999]"
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="fixed inset-0 bg-black/50" aria-hidden="true" />
|
||||
{/* Centered panel */}
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<Dialog.Panel className="relative outline-none">
|
||||
<div
|
||||
className={`rounded-xl shadow-xl border border-base bg-[var(--color-surface)] text-fg flex flex-col transition-all duration-300 overflow-hidden`}
|
||||
style={{
|
||||
width: isFullscreen ? "90vw" : "70rem",
|
||||
height: isFullscreen ? "90vh" : "38rem",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-6 py-4 border-b border-base select-none">
|
||||
<h2 className="text-base font-bold">
|
||||
Messkurve Messwerteingang {selectedId ?? "–"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setIsFullscreen((v) => !v)}
|
||||
className="icon-btn text-xl"
|
||||
aria-label={isFullscreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
type="button"
|
||||
title={isFullscreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullscreen
|
||||
? "bi bi-fullscreen-exit"
|
||||
: "bi bi-arrows-fullscreen"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => dispatch(setIsChartModalOpen(false))}
|
||||
className="icon-btn text-2xl"
|
||||
aria-label="Modal schließen"
|
||||
type="button"
|
||||
title="Schließen"
|
||||
>
|
||||
<i className="bi bi-x-circle-fill" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 px-4 pt-3 pb-4 bg-[var(--color-surface)]">
|
||||
<AnalogInputsChart loading={loading} setLoading={setLoading} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
93
components/main/analogInputs/AnalogInputsDatePicker.tsx
Normal file
93
components/main/analogInputs/AnalogInputsDatePicker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
// components/main/analogInputs/AnalogInputsDatePicker.tsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
|
||||
type Props = {
|
||||
from: string;
|
||||
to: string;
|
||||
onChange: (from: string, to: string) => void;
|
||||
};
|
||||
|
||||
export default function AnalogInputsDatePicker({ from, to, onChange }: Props) {
|
||||
const today = new Date();
|
||||
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const sixMonthsAgo = new Date();
|
||||
sixMonthsAgo.setMonth(today.getMonth() - 6);
|
||||
|
||||
// interne Date-Objekte für react-datepicker
|
||||
const parseISO = (dateStr: string) => {
|
||||
if (!dateStr) return null;
|
||||
const [year, month, day] = dateStr.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
};
|
||||
|
||||
const formatISO = (date: Date) => date.toLocaleDateString("sv-SE"); // yyyy-MM-dd
|
||||
|
||||
const [localFromDate, setLocalFromDate] = useState<Date | null>(
|
||||
from ? parseISO(from) : thirtyDaysAgo
|
||||
);
|
||||
const [localToDate, setLocalToDate] = useState<Date | null>(
|
||||
to ? parseISO(to) : today
|
||||
);
|
||||
|
||||
// Wenn Props von außen kommen (z.B. Reset), synchronisieren
|
||||
useEffect(() => {
|
||||
if (from) setLocalFromDate(parseISO(from));
|
||||
if (to) setLocalToDate(parseISO(to));
|
||||
}, [from, to]);
|
||||
|
||||
const handleFromChange = (date: Date | null) => {
|
||||
setLocalFromDate(date);
|
||||
if (date && localToDate) {
|
||||
onChange(formatISO(date), formatISO(localToDate));
|
||||
}
|
||||
};
|
||||
|
||||
const handleToChange = (date: Date | null) => {
|
||||
setLocalToDate(date);
|
||||
if (localFromDate && date) {
|
||||
onChange(formatISO(localFromDate), formatISO(date));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-4 items-center">
|
||||
{/* Von */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="block text-sm font-semibold">Von</label>
|
||||
<DatePicker
|
||||
selected={localFromDate}
|
||||
onChange={handleFromChange}
|
||||
selectsStart
|
||||
startDate={localFromDate}
|
||||
endDate={localToDate}
|
||||
minDate={sixMonthsAgo}
|
||||
maxDate={today}
|
||||
dateFormat="dd.MM.yyyy"
|
||||
className="border px-2 py-1 rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bis */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="block text-sm font-semibold">Bis</label>
|
||||
<DatePicker
|
||||
selected={localToDate}
|
||||
onChange={handleToChange}
|
||||
selectsEnd
|
||||
startDate={localFromDate}
|
||||
endDate={localToDate}
|
||||
minDate={sixMonthsAgo}
|
||||
maxDate={today}
|
||||
dateFormat="dd.MM.yyyy"
|
||||
className="border px-2 py-1 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,24 @@
|
||||
"use client"; // /components/main/analogeEingaenge/AnalogInputsSettingsModal.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import { setIsSettingsModalOpen } from "@/redux/slices/analogInputs/analogInputsUiSlice";
|
||||
|
||||
interface AnalogInput {
|
||||
id: number;
|
||||
label?: string;
|
||||
offset?: number | string;
|
||||
factor?: number | string;
|
||||
loggerInterval: string;
|
||||
unit?: string;
|
||||
}
|
||||
import type { AnalogInput } from "@/types/analogInput"; // 👈 Importiere den Typ (jetzt definiert und exportiert)
|
||||
|
||||
interface Props {
|
||||
selectedInput: AnalogInput;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
export default function AnalogInputsSettingsModal() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isOpen = useSelector(
|
||||
(state: RootState) => state.analogInputsUi.isSettingsModalOpen
|
||||
);
|
||||
|
||||
const selectedInput = useSelector<RootState, AnalogInput | null>(
|
||||
(state) => state.selectedAnalogInput
|
||||
);
|
||||
|
||||
export default function AnalogInputSettingsModal({
|
||||
selectedInput,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [label, setLabel] = useState("");
|
||||
const [offset, setOffset] = useState("0.000");
|
||||
const [factor, setFactor] = useState("1.000");
|
||||
@@ -28,6 +26,8 @@ export default function AnalogInputSettingsModal({
|
||||
const [unit, setUnit] = useState("V");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const unitOptions = ["V", "mA", "°C", "bar", "%"];
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedInput && isOpen) {
|
||||
setLabel(selectedInput.label || "");
|
||||
@@ -41,12 +41,8 @@ export default function AnalogInputSettingsModal({
|
||||
? selectedInput.factor.toFixed(3)
|
||||
: selectedInput.factor || "1.000"
|
||||
);
|
||||
setLoggerInterval(selectedInput.loggerInterval);
|
||||
setLoggerInterval(selectedInput.loggerInterval || "9");
|
||||
setUnit(selectedInput.unit || "V");
|
||||
console.log(
|
||||
"selectedInput in analoge Eingänge:",
|
||||
selectedInput.loggerInterval
|
||||
);
|
||||
}
|
||||
}, [selectedInput, isOpen]);
|
||||
|
||||
@@ -54,6 +50,7 @@ export default function AnalogInputSettingsModal({
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true);
|
||||
|
||||
const slot = selectedInput.id;
|
||||
const isDev = window.location.hostname === "localhost";
|
||||
|
||||
@@ -99,7 +96,7 @@ export default function AnalogInputSettingsModal({
|
||||
alert("Einstellungen gespeichert (Produktion).");
|
||||
}
|
||||
|
||||
onClose();
|
||||
dispatch(setIsSettingsModalOpen(false));
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
alert("Fehler beim Speichern.");
|
||||
@@ -111,108 +108,118 @@ export default function AnalogInputSettingsModal({
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 w-full h-full bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 w-1/2 max-w-lg">
|
||||
<div className="mb-4 border-b pb-2 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold">
|
||||
<div className="bg-[var(--color-surface)] border border-base rounded-xl shadow-xl w-[32rem] max-w-full p-0 flex flex-col">
|
||||
<header className="modal-header flex items-center justify-between px-6 py-4 border-b border-base">
|
||||
<h2 className="text-base font-bold text-fg">
|
||||
Einstellungen Messwerteingang {selectedInput.id}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl hover:text-gray-400"
|
||||
onClick={() => dispatch(setIsSettingsModalOpen(false))}
|
||||
className="icon-btn text-2xl"
|
||||
aria-label="Modal schließen"
|
||||
type="button"
|
||||
>
|
||||
<i className="bi bi-x-circle-fill"></i>
|
||||
<i className="bi bi-x-circle-fill" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bezeichnung */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<span className="font-normal">Bezeichnung:</span>
|
||||
</div>
|
||||
<div>
|
||||
</header>
|
||||
<div className="modal-body-scroll px-6 py-5 flex-1 text-fg">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<span className="font-normal text-fg-secondary">Bezeichnung:</span>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full border rounded px-3 py-1 mb-4"
|
||||
className="border border-base rounded px-2 py-1 w-full bg-[var(--color-surface-alt)] text-fg"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Offset */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 mb-4">
|
||||
<div>
|
||||
<span className="font-normal">Offset:</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 mb-4">
|
||||
<span className="font-normal text-fg-secondary">Offset:</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
className="border border-gray-300 rounded px-2 py-1 w-full text-right "
|
||||
className="border border-base rounded px-2 py-1 w-full text-right bg-[var(--color-surface-alt)] text-fg"
|
||||
value={offset}
|
||||
onChange={(e) => setOffset(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Faktor */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 mb-4">
|
||||
<div>
|
||||
<span className="font-normal">Faktor:</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 mb-4">
|
||||
<span className="font-normal text-fg-secondary">Faktor:</span>
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
className="border border-gray-300 rounded px-2 py-1 w-full text-right"
|
||||
className="border border-base rounded px-2 py-1 w-full text-right bg-[var(--color-surface-alt)] text-fg"
|
||||
value={factor}
|
||||
onChange={(e) => setFactor(e.target.value)}
|
||||
/>{" "}
|
||||
</div>
|
||||
</div>
|
||||
{/* Einheit */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<span className="font-normal">Einheit:</span>
|
||||
</div>
|
||||
<div>
|
||||
<select
|
||||
className="w-full border rounded px-3 py-1 mb-4"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value)}
|
||||
>
|
||||
<option value="V">V</option>
|
||||
<option value="mA">mA</option>
|
||||
<option value="°C">°C</option>
|
||||
<option value="bar">bar</option>
|
||||
<option value="%">%</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/* Loggerintervall/Speicherintervall */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 ">
|
||||
<span className="font-normal">Speicherintervall:</span>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="number"
|
||||
className="border rounded px-2 py-1 pr-20 w-full text-right"
|
||||
value={loggerInterval}
|
||||
onChange={(e) => setLoggerInterval(e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
|
||||
Minuten
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3 mb-4">
|
||||
<span className="font-normal text-fg-secondary">Einheit:</span>
|
||||
<Listbox value={unit} onChange={setUnit}>
|
||||
<div className="relative w-full">
|
||||
<Listbox.Button className="w-full border border-base px-2 py-1 rounded text-left bg-[var(--color-surface-alt)] text-fg flex justify-between items-center text-sm font-sans">
|
||||
<span>{unit}</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-muted"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.585l3.71-3.355a.75.75 0 111.02 1.1l-4.25 3.85a.75.75 0 01-1.02 0l-4.25-3.85a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="absolute z-50 mt-1 w-full border border-base rounded bg-[var(--color-surface-alt)] shadow max-h-60 overflow-auto text-sm text-fg font-sans">
|
||||
{unitOptions.map((opt) => (
|
||||
<Listbox.Option
|
||||
key={opt}
|
||||
value={opt}
|
||||
className={({ selected, active }) =>
|
||||
`px-4 py-1 cursor-pointer ${
|
||||
selected
|
||||
? "bg-littwin-blue text-white font-medium"
|
||||
: active
|
||||
? "bg-base-muted"
|
||||
: "text-fg"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{opt}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<span className="font-normal text-fg-secondary">
|
||||
Speicherintervall:
|
||||
</span>
|
||||
<div className="relative w-full">
|
||||
<input
|
||||
type="number"
|
||||
className="border border-base rounded px-2 py-1 pr-20 w-full text-right bg-[var(--color-surface-alt)] text-fg"
|
||||
value={loggerInterval}
|
||||
onChange={(e) => setLoggerInterval(e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted text-sm">
|
||||
Minuten
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<footer className="px-6 py-4 border-t border-base flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
className="btn-primary px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
{isSaving ? "Speichern..." : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,18 +7,17 @@ import { getAnalogInputsThunk } from "@/redux/thunks/getAnalogInputsThunk";
|
||||
import { Icon } from "@iconify/react";
|
||||
import settingsIcon from "@iconify/icons-mdi/settings";
|
||||
import waveformIcon from "@iconify/icons-mdi/waveform";
|
||||
import { setSelectedAnalogInput } from "@/redux/slices/selectedAnalogInputSlice";
|
||||
|
||||
export default function AnalogInputsTable({
|
||||
import { setSelectedAnalogInput } from "@/redux/slices/analogInputs/selectedAnalogInputSlice";
|
||||
import { setIsSettingsModalOpen } from "@/redux/slices/analogInputs/analogInputsUiSlice";
|
||||
import { setIsChartModalOpen } from "@/redux/slices/analogInputs/analogInputsUiSlice";
|
||||
import {
|
||||
setSelectedId,
|
||||
setSelectedInput,
|
||||
setIsSettingsModalOpen,
|
||||
}: {
|
||||
setSelectedId: (id: number) => void;
|
||||
setSelectedInput: (input: AnalogInput) => void;
|
||||
setIsSettingsModalOpen: (open: boolean) => void;
|
||||
}) {
|
||||
setAutoLoad,
|
||||
} from "@/redux/slices/analogInputs/analogInputsHistorySlice";
|
||||
|
||||
export default function AnalogInputsTable({ loading }: { loading: boolean }) {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
const [activeId, setActiveId] = React.useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,13 +29,18 @@ export default function AnalogInputsTable({
|
||||
);
|
||||
|
||||
const handleSelect = (id: number, input: AnalogInput) => {
|
||||
setSelectedId(id);
|
||||
dispatch(setSelectedId(id));
|
||||
setActiveId(id);
|
||||
dispatch(setSelectedAnalogInput(input)); // 🧠 hier kommt die Bezeichnung in Redux
|
||||
dispatch(setSelectedAnalogInput(input));
|
||||
dispatch(setAutoLoad(true));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-md border border-gray-200 p-3 rounded-lg w-full laptop:p-1 xl:p-1">
|
||||
<div
|
||||
className={`text-[var(--color-fg)] bg-[var(--color-surface)] dark:bg-[var(--color-surface)] shadow-sm border border-[var(--color-border)] p-3 rounded-lg laptop:p-1 xl:p-1 ${
|
||||
loading ? "cursor-wait opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
<h2 className="laptop:text-sm md:text-base 2xl:text-lg font-bold mb-3 flex items-center">
|
||||
<Icon
|
||||
icon={waveformIcon}
|
||||
@@ -45,69 +49,113 @@ export default function AnalogInputsTable({
|
||||
Messwerteingänge
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs laptop:text-[10px] xl:text-xs 2xl:text-sm border-collapse">
|
||||
<thead className="bg-gray-100 border-b items-center ">
|
||||
<table
|
||||
className={`text-xs laptop:text-[10px] xl:text-xs 2xl:text-sm border-collapse w-full ${
|
||||
loading ? "cursor-wait" : ""
|
||||
}`}
|
||||
>
|
||||
<thead className="bg-[var(--color-surface-alt)]/60 dark:bg-[var(--color-surface-alt)]/30 text-[var(--color-fg)] border-b border-[var(--color-border)] items-center">
|
||||
<tr>
|
||||
<th className="border p-1 text-left">Eingang</th>
|
||||
<th className="border p-1 text-left">Messwert</th>
|
||||
<th className="border p-1 text-left">Einheit</th>
|
||||
<th className="border p-1 text-left">Bezeichnung</th>
|
||||
<th className="border p-1 text-left">Aktion</th>
|
||||
<th className="border p-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
Eingang
|
||||
</th>
|
||||
<th className="border p-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
Messwert
|
||||
</th>
|
||||
<th className="border p-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
Einheit
|
||||
</th>
|
||||
<th className="border p-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
Bezeichnung
|
||||
</th>
|
||||
<th className="border p-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
Einstellungen
|
||||
</th>
|
||||
<th className="border p-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
Messkurve
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.values(analogInputs)
|
||||
.filter(
|
||||
(e) =>
|
||||
e && typeof e.id === "number" && typeof e.label === "string"
|
||||
(analogInput) =>
|
||||
analogInput &&
|
||||
typeof analogInput.id === "number" &&
|
||||
typeof analogInput.label === "string"
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((e, index) => (
|
||||
.map((analogInput, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={`transition cursor-pointer ${
|
||||
e.id === activeId ? "bg-blue-100" : "hover:bg-gray-100"
|
||||
loading
|
||||
? "cursor-wait"
|
||||
: analogInput.id === activeId
|
||||
? "bg-[var(--color-accent-soft)] dark:bg-[var(--color-surface-alt)]/60 text-[var(--color-fg)]"
|
||||
: "hover:bg-[var(--color-surface-alt)]/70 dark:hover:bg-[var(--color-surface-alt)]/30"
|
||||
}`}
|
||||
>
|
||||
<td
|
||||
className="border p-2"
|
||||
onClick={() => handleSelect(e.id!, e)}
|
||||
className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]"
|
||||
onClick={() => handleSelect(analogInput.id!, analogInput)}
|
||||
>
|
||||
<div className="flex items-center gap-1 ">
|
||||
<Icon
|
||||
icon={waveformIcon}
|
||||
className="text-gray-600 text-base laptop:text-sm xl:text-sm 2xl:text-lg"
|
||||
/>
|
||||
{e.id ?? "-"}
|
||||
{analogInput.id ?? "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className="border p-2 text-right"
|
||||
onClick={() => handleSelect(e.id!, e)}
|
||||
className="border p-2 text-right bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]"
|
||||
onClick={() => handleSelect(analogInput.id!, analogInput)}
|
||||
>
|
||||
{typeof e.value === "number" ? e.value.toFixed(2) : "-"}
|
||||
{typeof analogInput.value === "number"
|
||||
? analogInput.value.toFixed(2)
|
||||
: "-"}
|
||||
</td>
|
||||
|
||||
<td className="border p-2">{e.unit || "-"}</td>
|
||||
<td
|
||||
className="border p-2"
|
||||
onClick={() => handleSelect(e.id!, e)}
|
||||
className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]"
|
||||
onClick={() => handleSelect(analogInput.id!, analogInput)}
|
||||
>
|
||||
{e.label || "----"}
|
||||
{analogInput.unit || "-"}
|
||||
</td>
|
||||
<td
|
||||
className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]"
|
||||
onClick={() => handleSelect(analogInput.id!, analogInput)}
|
||||
>
|
||||
{analogInput.label || "----"}
|
||||
</td>
|
||||
|
||||
<td className="border p-2 text-center">
|
||||
<td className="border p-2 text-center bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSelect(e.id!, e);
|
||||
setSelectedInput(e);
|
||||
setIsSettingsModalOpen(true);
|
||||
handleSelect(analogInput.id!, analogInput);
|
||||
dispatch(setIsSettingsModalOpen(true));
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-500"
|
||||
className="text-gray-400 hover:text-gray-500 dark:text-gray-300 dark:hover:text-white"
|
||||
>
|
||||
<Icon icon={settingsIcon} className="text-xl" />
|
||||
</button>
|
||||
</td>
|
||||
<td className="border p-2 text-center bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
handleSelect(analogInput.id!, analogInput);
|
||||
dispatch(setIsChartModalOpen(true));
|
||||
}}
|
||||
className="text-gray-500 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white"
|
||||
title="Messkurve anzeigen"
|
||||
aria-label="Messkurve anzeigen"
|
||||
>
|
||||
<span role="img" aria-hidden="true" className="text-lg">
|
||||
📈
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
54
components/main/analogInputs/AnalogInputsView.tsx
Normal file
54
components/main/analogInputs/AnalogInputsView.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
// components/main/analogInputs/AnalogInputsView.tsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import AnalogInputsTable from "@/components/main/analogInputs/AnalogInputsTable";
|
||||
import AnalogInputsChartModal from "@/components/main/analogInputs/AnalogInputsChartModal";
|
||||
import AnalogInputsSettingsModal from "@/components/main/analogInputs/AnalogInputsSettingsModal";
|
||||
import { getAnalogInputsThunk } from "@/redux/thunks/getAnalogInputsThunk";
|
||||
import { useAppDispatch } from "@/redux/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
|
||||
function AnalogInputsView() {
|
||||
const [loading, setLoading] = useState(false); // Add loading state
|
||||
|
||||
const selectedInput = useSelector(
|
||||
(state: RootState) => state.selectedAnalogInput
|
||||
);
|
||||
// selectedId is now displayed within the modal header
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
dispatch(getAnalogInputsThunk());
|
||||
const interval = setInterval(() => {
|
||||
dispatch(getAnalogInputsThunk());
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col justify-start gap-3 p-4 h-[calc(100vh-13vh-8vh)] ${
|
||||
loading ? "cursor-wait" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 justify-items-start">
|
||||
<div className="rounded-lg p-4 max-w-3xl text-[var(--color-fg)] bg-[var(--color-surface)] dark:bg-[var(--color-surface)] border border-[var(--color-border)] shadow-sm">
|
||||
<h2 className="text-xl font-semibold mb-4 text-[var(--color-fg)] tracking-wide">
|
||||
Messwerteingänge
|
||||
</h2>
|
||||
<AnalogInputsTable loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedInput !== null && <AnalogInputsSettingsModal />}
|
||||
{/* Chart Modal */}
|
||||
<AnalogInputsChartModal loading={loading} setLoading={setLoading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AnalogInputsView;
|
||||
@@ -43,7 +43,7 @@ const Baugruppentraeger: React.FC = () => {
|
||||
baugruppen.push(
|
||||
<div
|
||||
key={i}
|
||||
className="flex bg-white shadow-md rounded-lg mb-4 xl:mb-0 lg:mb-0 border border-gray-200 w-full laptop:scale-y-75 xl:scale-y-90"
|
||||
className="flex card mb-4 xl:mb-0 lg:mb-0 w-full laptop:scale-y-75 xl:scale-y-90"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{slots.map((version, index) => {
|
||||
|
||||
62
components/main/dashboard/DashboardView.tsx
Normal file
62
components/main/dashboard/DashboardView.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
// components/main/dashboard/DashboardView.tsx
|
||||
import React, { useEffect } from "react";
|
||||
import "tailwindcss/tailwind.css";
|
||||
import "@fontsource/roboto";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import { Icon } from "@iconify/react";
|
||||
import Last20MessagesTable from "@/components/main/dashboard/Last20MessagesTable";
|
||||
import NetworkInfo from "@/components/main/dashboard/NetworkInfo";
|
||||
import VersionInfo from "@/components/main/dashboard/VersionInfo";
|
||||
import Baugruppentraeger from "@/components/main/dashboard/Baugruppentraeger";
|
||||
import { getLast20MessagesThunk } from "@/redux/thunks/getLast20MessagesThunk";
|
||||
import { useAppDispatch } from "@/redux/store";
|
||||
|
||||
const DashboardView: React.FC = () => {
|
||||
//-------------------------------------
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
dispatch(getLast20MessagesThunk());
|
||||
const interval = setInterval(() => {
|
||||
dispatch(getLast20MessagesThunk());
|
||||
}, 10000); // oder 5000
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]);
|
||||
//-------------------------------------
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 h-[calc(100vh-13vh-8vh)] laptop:h-[calc(100vh-10vh-5vh)] xl:h-[calc(100vh-10vh-6vh)] laptop:gap-0 bg-[var(--color-background)] text-[var(--color-fg)]">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center w-full lg:w-2/3">
|
||||
<div className="flex justify-between gap-1">
|
||||
<Icon
|
||||
icon="ri:calendar-schedule-line"
|
||||
className="text-littwin-blue text-4xl xl:text-2xl"
|
||||
/>
|
||||
<h1 className="text-xl font-bold xl:text-base text-[var(--color-fg)] tracking-wide">
|
||||
Letzten 20 Meldungen
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hauptbereich mit Meldungstabelle und Baugruppenträger */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 flex-grow overflow-hidden pt-4">
|
||||
<Last20MessagesTable className="w-full lg:w-2/3 h-full" />
|
||||
|
||||
<div className="shadow-md rounded-lg w-full lg:w-1/3 flex flex-col gap-2">
|
||||
<VersionInfo className="w-full p-3 text-sm" />
|
||||
|
||||
{/* Baugruppenträger jetzt mit voller Breite */}
|
||||
<div className="overflow-auto max-h-[50vh]">
|
||||
<Baugruppentraeger />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NetworkInfo in einem div ,nimmt die gesamte Breite */}
|
||||
<NetworkInfo />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardView;
|
||||
@@ -48,35 +48,56 @@ export default function Last20MessagesTable({ className }: Props) {
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 p-4 ${className}`}>
|
||||
<div className="overflow-auto max-h-[80vh]">
|
||||
<table className="min-w-full border">
|
||||
<thead className="bg-gray-100 text-left sticky top-0 z-10">
|
||||
<table className="min-w-full border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
<thead className="text-left sticky top-0 z-10 bg-[var(--color-surface-alt)]/70 dark:bg-[var(--color-surface-alt)]/25 text-[var(--color-fg)]">
|
||||
<tr>
|
||||
<th className="p-2 border">Prio</th>
|
||||
<th className="p-2 border">Zeitstempel</th>
|
||||
<th className="p-2 border">Quelle</th>
|
||||
<th className="p-2 border">Meldung</th>
|
||||
<th className="p-2 border">Status</th>
|
||||
<th className="p-2 border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Prio
|
||||
</th>
|
||||
<th className="p-2 border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Zeitstempel
|
||||
</th>
|
||||
<th className="p-2 border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Quelle
|
||||
</th>
|
||||
<th className="p-2 border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Meldung
|
||||
</th>
|
||||
<th className="p-2 border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMessages.slice(0, 20).map((msg, index) => (
|
||||
<tr key={index} className="hover:bg-gray-50">
|
||||
<td className="border p-2">
|
||||
<tr
|
||||
key={index}
|
||||
className="hover:bg-[var(--color-surface-alt)]/70 dark:hover:bg-[var(--color-surface-alt)]/30 transition"
|
||||
>
|
||||
<td className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: msg.c }}
|
||||
></div>
|
||||
</td>
|
||||
<td className="border p-2">{msg.t}</td>
|
||||
<td className="border p-2">{msg.i}</td>
|
||||
<td className="border p-2">{msg.m}</td>
|
||||
<td className="border p-2">{msg.v}</td>
|
||||
<td className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
{msg.t}
|
||||
</td>
|
||||
<td className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
{msg.i}
|
||||
</td>
|
||||
<td className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
{msg.m}
|
||||
</td>
|
||||
<td className="border p-2 bg-[var(--color-surface)] text-[var(--color-fg)] border-[var(--color-border)]">
|
||||
{msg.v}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-4 text-center text-gray-500 italic">
|
||||
<div className="mt-4 text-center italic text-[var(--color-fg-muted)]">
|
||||
Keine Meldungen im gewählten Zeitraum vorhanden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,7 @@ const NetworkInfo: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="w-full flex-direction: row flex">
|
||||
<div className=" flex-grow flex justify-between items-center mt-1 bg-white p-2 rounded-lg shadow-md border border-gray-200 laptop:m-0 laptop:scale-y-75 2xl:scale-y-75">
|
||||
<div className=" flex-grow flex justify-between items-center mt-1 p-2 rounded-lg shadow-sm bg-[var(--color-surface)] dark:bg-[var(--color-surface)] border border-[var(--color-border)] laptop:m-0 laptop:scale-y-75 2xl:scale-y-75">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Image
|
||||
src="/images/IP-icon.svg"
|
||||
@@ -49,8 +49,8 @@ const NetworkInfo: React.FC = () => {
|
||||
priority
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">IP-Adresse</p>
|
||||
<p className="text-sm font-medium text-gray-700">{ip}</p>
|
||||
<p className="text-xs text-[var(--color-fg-muted)]">IP-Adresse</p>
|
||||
<p className="text-sm font-medium text-[var(--color-fg)]">{ip}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -64,8 +64,10 @@ const NetworkInfo: React.FC = () => {
|
||||
priority
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Subnet-Maske</p>
|
||||
<p className="text-sm font-medium text-gray-700">{subnet}</p>
|
||||
<p className="text-xs text-[var(--color-fg-muted)]">Subnet-Maske</p>
|
||||
<p className="text-sm font-medium text-[var(--color-fg)]">
|
||||
{subnet}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -79,16 +81,20 @@ const NetworkInfo: React.FC = () => {
|
||||
priority
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Gateway</p>
|
||||
<p className="text-sm font-medium text-gray-700">{gateway}</p>
|
||||
<p className="text-xs text-[var(--color-fg-muted)]">Gateway</p>
|
||||
<p className="text-sm font-medium text-[var(--color-fg)]">
|
||||
{gateway}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-xs font-bold text-littwin-blue">OPC-UA</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Status</p>
|
||||
<p className="text-sm font-medium text-gray-700">{opcUaZustand}</p>
|
||||
<p className="text-xs text-[var(--color-fg-muted)]">Status</p>
|
||||
<p className="text-sm font-medium text-[var(--color-fg)]">
|
||||
{opcUaZustand}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* OPC UA Nodeset Name */}
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Icon } from "@iconify/react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../../redux/store";
|
||||
|
||||
type VersionInfoProps = {
|
||||
className?: string;
|
||||
};
|
||||
type VersionInfoProps = { className?: string };
|
||||
|
||||
const VersionInfo: React.FC<VersionInfoProps> = ({ className = "" }) => {
|
||||
const appVersion =
|
||||
@@ -17,24 +15,26 @@ const VersionInfo: React.FC<VersionInfoProps> = ({ className = "" }) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-gray-50 rounded-lg shadow-sm border border-gray-200 w-full laptop:p-2 ${className}`}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-gray-700 mb-2">
|
||||
<div className={`card w-full p-3 laptop:p-2 ${className}`}>
|
||||
<h2 className="text-base font-semibold mb-2 text-[var(--color-fg)]">
|
||||
Versionsinformationen
|
||||
</h2>
|
||||
|
||||
<div className="flex flex-row p-2 space-x-2">
|
||||
<Icon icon="bx:code-block" className="text-xl text-blue-400" />
|
||||
<p className="text-sm text-gray-600">
|
||||
Applikationsversion: {appVersion}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row p-2 space-x-2">
|
||||
<Icon icon="mdi:web" className="text-xl text-blue-400" />
|
||||
<p className="text-sm text-gray-600">Webversion: {webVersion}</p>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="bx:code-block" className="text-xl text-accent" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Applikationsversion:{" "}
|
||||
<span className="text-[var(--color-fg)]">{appVersion}</span>
|
||||
</p>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="mdi:web" className="text-xl text-accent" />
|
||||
<p className="text-sm text-fg-muted">
|
||||
Webversion:{" "}
|
||||
<span className="text-[var(--color-fg)]">{webVersion}</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,10 +18,18 @@ const KabelModulStatus: React.FC<KabelModulStatusProps> = ({
|
||||
// Modultyp basierend auf der Version bestimmen
|
||||
let moduleName = "";
|
||||
let moduleType = "";
|
||||
|
||||
if (moduleVersion === 419) {
|
||||
moduleName = "KÜ705";
|
||||
moduleType = "FO";
|
||||
} else if (moduleVersion === 420) {
|
||||
moduleName = "KÜ705";
|
||||
moduleType = "FO";
|
||||
} else if (moduleVersion === 421) {
|
||||
moduleName = "KÜ705";
|
||||
moduleType = "FO";
|
||||
} else if (moduleVersion === 431) {
|
||||
moduleName = "KÜ705";
|
||||
moduleType = "FO";
|
||||
} else if (moduleVersion === 350) {
|
||||
moduleName = "KÜ605";
|
||||
moduleType = "µC";
|
||||
|
||||
70
components/main/digitalInputs/DigitalInputsView.tsx
Normal file
70
components/main/digitalInputs/DigitalInputsView.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// components/main/digitalInputs/DigitalInputsView.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { AppDispatch } from "@/redux/store";
|
||||
|
||||
import InputModal from "@/components/main/digitalInputs/digitalInputsModal";
|
||||
import { getDigitalInputsThunk } from "@/redux/thunks/getDigitalInputsThunk";
|
||||
import DigitalInputsWidget from "@/components/main/digitalInputs/DigitalInputsWidget";
|
||||
|
||||
const DigitalInputsView: React.FC = () => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
|
||||
interface DigitalInput {
|
||||
id: number;
|
||||
eingangOffline: boolean;
|
||||
status: boolean;
|
||||
label: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const [selectedInput, setSelectedInput] = useState<DigitalInput | null>(null);
|
||||
const [isInputModalOpen, setIsInputModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getDigitalInputsThunk());
|
||||
const interval = setInterval(() => {
|
||||
dispatch(getDigitalInputsThunk());
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]);
|
||||
|
||||
const openInputModal = (input: DigitalInput) => {
|
||||
setSelectedInput(input);
|
||||
setIsInputModalOpen(true);
|
||||
};
|
||||
|
||||
const closeInputModal = () => {
|
||||
setSelectedInput(null);
|
||||
setIsInputModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 h-[calc(100vh-13vh-8vh)] laptop:h-[calc(100vh-10vh-5vh)] xl:h-[calc(100vh-10vh-6vh)] laptop:gap-0">
|
||||
<h1 className="text-base font-semibold mb-2">Meldungseingänge</h1>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 items-start ">
|
||||
<DigitalInputsWidget
|
||||
openInputModal={openInputModal}
|
||||
inputRange={{ start: 0, end: 16 }}
|
||||
/>
|
||||
<DigitalInputsWidget
|
||||
openInputModal={openInputModal}
|
||||
inputRange={{ start: 16, end: 32 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isInputModalOpen && selectedInput && (
|
||||
<InputModal
|
||||
selectedInput={selectedInput}
|
||||
closeInputModal={closeInputModal}
|
||||
isOpen={isInputModalOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DigitalInputsView;
|
||||
@@ -30,7 +30,7 @@ export default function DigitalInputsWidget({
|
||||
//console.log("DigitalInputs", inputs);
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-md border border-gray-200 p-3 rounded-lg w-full laptop:p-1 xl:p-1">
|
||||
<div className="text-[var(--color-fg)] bg-[var(--color-surface)] dark:bg-[var(--color-surface)] shadow-sm border border-[var(--color-border)] p-3 rounded-lg w-full laptop:p-1 xl:p-1">
|
||||
<h2 className="laptop:text-sm md:text-base 2xl:text-lg font-bold mb-3 flex items-center">
|
||||
<Icon
|
||||
icon={inputIcon}
|
||||
@@ -38,19 +38,30 @@ export default function DigitalInputsWidget({
|
||||
/>
|
||||
Meldungseingänge {inputRange.start + 1} – {inputRange.end}
|
||||
</h2>
|
||||
<table className="w-full text-xs laptop:text-[10px] xl:text-xs 2xl:text-sm border-collapse">
|
||||
<thead className="bg-gray-100 border-b">
|
||||
<table className="w-full text-xs laptop:text-[10px] xl:text-xs 2xl:text-sm border-collapse bg-[var(--color-surface)]">
|
||||
<thead className="bg-[var(--color-surface-alt)]/60 dark:bg-[var(--color-surface-alt)]/25 text-[var(--color-fg)] border-b border-[var(--color-border)]">
|
||||
<tr>
|
||||
<th className="px-1 py-1 text-left">Eingang</th>
|
||||
<th className="px-1 py-1 text-left">Zustand</th>
|
||||
<th className="px-1 py-1 text-left">Bezeichnung</th>
|
||||
<th className="px-1 py-1 text-left">Aktion</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Eingang
|
||||
</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Zustand
|
||||
</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Bezeichnung
|
||||
</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{inputs.map((input) => (
|
||||
<tr key={input.id} className="border-b">
|
||||
<td className="px-1 py-0">
|
||||
<tr
|
||||
key={input.id}
|
||||
className="border-b border-[var(--color-border)] hover:bg-[var(--color-surface-alt)]/70 dark:hover:bg-[var(--color-surface-alt)]/30 transition"
|
||||
>
|
||||
<td className="px-1 py-0 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
<div className="flex items-center gap-1 ">
|
||||
<Icon
|
||||
icon={loginIcon}
|
||||
@@ -59,7 +70,7 @@ export default function DigitalInputsWidget({
|
||||
{input.id}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-1 py-1 ">
|
||||
<td className="px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
{input.eingangOffline ? (
|
||||
<div className="relative group inline-block">
|
||||
<span className="text-red-500 sm:text-sm md:text-base lg:text-lg xl:text-xl 2xl:text-2xl laptop:text-sm ">
|
||||
@@ -80,11 +91,13 @@ export default function DigitalInputsWidget({
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-1 py-1">{input.label}</td>
|
||||
<td className="px-1 py-1">
|
||||
<td className="px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
{input.label}
|
||||
</td>
|
||||
<td className="px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
<Icon
|
||||
icon={settingsIcon}
|
||||
className="text-gray-400 text-base laptop:text-sm xl:text-sm 2xl:text-lg cursor-pointer"
|
||||
className="text-gray-400 text-base laptop:text-sm xl:text-sm 2xl:text-lg cursor-pointer dark:text-gray-300 dark:hover:text-white"
|
||||
onClick={() => openInputModal(input)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -182,132 +182,133 @@ export default function InputModal({
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 w-full h-full bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 w-1/2 max-w-lg">
|
||||
<div className="mb-4 border-b pb-2 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold">
|
||||
<div className="bg-[var(--color-surface)] border border-base rounded-xl shadow-xl w-[32rem] max-w-full p-0 flex flex-col">
|
||||
<header className="modal-header flex items-center justify-between px-6 py-4 border-b border-base">
|
||||
<h2 className="text-base font-bold text-fg">
|
||||
Einstellungen Meldungseingang {selectedInput.id}
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-2xl hover:text-gray-400"
|
||||
className="icon-btn text-2xl"
|
||||
aria-label="Modal schließen"
|
||||
type="button"
|
||||
>
|
||||
<i className="bi bi-x-circle-fill"></i>
|
||||
<i className="bi bi-x-circle-fill" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<span className="font-normal">Bezeichnung:</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="border border-gray-300 rounded px-2 py-1 w-full"
|
||||
maxLength={32}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-normal">Invertierung:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={invertiert}
|
||||
onClick={() => setInvertiert(!invertiert)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ${
|
||||
invertiert ? "bg-littwin-blue" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${
|
||||
invertiert ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
</header>
|
||||
<div className="modal-body-scroll px-6 py-5 flex-1 text-fg">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<span className="font-normal text-fg-secondary">
|
||||
Bezeichnung:
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="border border-base rounded px-2 py-1 w-full bg-[var(--color-surface-alt)] text-fg"
|
||||
maxLength={32}
|
||||
/>
|
||||
</button>
|
||||
<span>{invertiert ? "Ein" : "Aus"}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-normal">Filterzeit:</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={timeFilter}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val <= 2000) {
|
||||
setTimeFilter(val);
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-2 py-1 pr-10 w-full text-right"
|
||||
title="Maximal 2000 ms erlaubt"
|
||||
/>
|
||||
|
||||
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
|
||||
ms
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-normal">Gewichtung:</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={weighting}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val <= 1000) {
|
||||
setWeighting(val);
|
||||
}
|
||||
}}
|
||||
className="border border-gray-300 rounded px-2 py-1 w-full text-right"
|
||||
title="Maximal 1000 erlaubt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group inline-block">
|
||||
<span className="font-normal">Out of Service:</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!eingangOffline}
|
||||
onClick={() => setEingangOffline(eingangOffline ? 0 : 1)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ${
|
||||
eingangOffline ? "bg-littwin-blue" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${
|
||||
eingangOffline ? "translate-x-6" : "translate-x-1"
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-normal text-fg-secondary">
|
||||
Invertierung:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={invertiert}
|
||||
onClick={() => setInvertiert(!invertiert)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full border border-base transition-colors duration-200 ${
|
||||
invertiert ? "bg-littwin-blue" : "bg-base-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1/2 -translate-y-1/2 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
invertiert ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-fg">{invertiert ? "Ein" : "Aus"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-normal text-fg-secondary">Filterzeit:</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={2000}
|
||||
value={timeFilter}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val <= 2000) {
|
||||
setTimeFilter(val);
|
||||
}
|
||||
}}
|
||||
className="border border-base rounded px-2 py-1 pr-10 w-full text-right bg-[var(--color-surface-alt)] text-fg"
|
||||
title="Maximal 2000 ms erlaubt"
|
||||
/>
|
||||
</button>
|
||||
<span>{eingangOffline ? "Ein" : "Aus"}</span>
|
||||
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-muted text-sm">
|
||||
ms
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-normal text-fg-secondary">Gewichtung:</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={weighting}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
if (val <= 1000) {
|
||||
setWeighting(val);
|
||||
}
|
||||
}}
|
||||
className="border border-base rounded px-2 py-1 w-full text-right bg-[var(--color-surface-alt)] text-fg"
|
||||
title="Maximal 1000 erlaubt"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative group inline-block">
|
||||
<span className="font-normal text-fg-secondary">
|
||||
Out of Service:
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!eingangOffline}
|
||||
onClick={() => setEingangOffline(eingangOffline ? 0 : 1)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full border border-base transition-colors duration-200 ${
|
||||
eingangOffline ? "bg-littwin-blue" : "bg-base-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-1 top-1/2 -translate-y-1/2 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
eingangOffline ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-fg">{eingangOffline ? "Ein" : "Aus"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<footer className="px-6 py-4 border-t border-base flex justify-end">
|
||||
<button
|
||||
onClick={handleSpeichern}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
className="btn-primary px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -49,22 +49,22 @@ export default function DigitalOutputsModal({
|
||||
|
||||
try {
|
||||
if (isCPL) {
|
||||
// ✅ Name speichern (DANx=...)
|
||||
const nameEncoded = encodeURIComponent(label.trim());
|
||||
const nameUrl = `/CPL?digitalOutputs.html&DAN0${selectedOutput.id}=${nameEncoded}`;
|
||||
|
||||
// ✅ Status speichern (DASx=...)
|
||||
const statusUrl = `/CPL?digitalOutputs.html&DAS0${selectedOutput.id}=${
|
||||
status ? 1 : 0
|
||||
}`;
|
||||
|
||||
// 🟢 Beide nacheinander senden (wichtig bei älteren CPL-Versionen)
|
||||
window.location.href = nameUrl; // Name zuerst (ggf. durch Refresh überschrieben)
|
||||
setTimeout(() => {
|
||||
window.location.href = statusUrl;
|
||||
}, 300); // kleine Verzögerung (optional)
|
||||
try {
|
||||
await fetch(nameUrl, { method: "GET" });
|
||||
await new Promise((res) => setTimeout(res, 300));
|
||||
await fetch(statusUrl, { method: "GET" });
|
||||
|
||||
// 💡 Modal wird nicht automatisch geschlossen — da Seite neu lädt.
|
||||
closeOutputModal(); // Seite bleibt erhalten
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler bei fetch:", err);
|
||||
setErrorMsg("❌ Fehler beim Speichern.");
|
||||
}
|
||||
} else {
|
||||
// 🧪 Lokaler Entwicklungsmodus
|
||||
const res = await fetch("/api/cpl/updateDigitalOutputsHandler", {
|
||||
@@ -94,44 +94,48 @@ export default function DigitalOutputsModal({
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 w-full h-full bg-black bg-opacity-50 flex justify-center items-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 w-1/2 max-w-lg">
|
||||
<div className="mb-4 border-b pb-2 flex justify-between items-center">
|
||||
<h2 className="text-base font-bold">
|
||||
<div className="bg-[var(--color-surface)] border border-base rounded-xl shadow-xl w-[32rem] max-w-full p-0 flex flex-col">
|
||||
<header className="modal-header flex items-center justify-between px-6 py-4 border-b border-base">
|
||||
<h2 className="text-base font-bold text-fg">
|
||||
Einstellungen Schaltausgang {selectedOutput.id}
|
||||
</h2>
|
||||
<button
|
||||
onClick={closeOutputModal}
|
||||
className="text-2xl hover:text-gray-400"
|
||||
className="icon-btn text-2xl"
|
||||
aria-label="Modal schließen"
|
||||
type="button"
|
||||
>
|
||||
<i className="bi bi-x-circle-fill"></i>
|
||||
<i className="bi bi-x-circle-fill" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<span className="font-normal">Bezeichnung:</span>
|
||||
</header>
|
||||
<div className="modal-body-scroll px-6 py-5 flex-1 text-fg">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<div>
|
||||
<span className="font-normal text-fg-secondary">
|
||||
Bezeichnung:
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="border border-base rounded px-2 py-1 w-full bg-[var(--color-surface-alt)] text-fg"
|
||||
placeholder="z. B. Licht Relais 1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z. B. Licht Relais 1"
|
||||
/>
|
||||
{errorMsg && <p className="text-red-600 text-sm mb-2">{errorMsg}</p>}
|
||||
</div>
|
||||
|
||||
{errorMsg && <p className="text-red-600 text-sm mb-2">{errorMsg}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<footer className="px-6 py-4 border-t border-base flex justify-end">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
className="btn-primary px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
{isSaving ? "Speichern..." : "Speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
61
components/main/digitalOutputs/DigitalOutputsView.tsx
Normal file
61
components/main/digitalOutputs/DigitalOutputsView.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { AppDispatch } from "@/redux/store";
|
||||
|
||||
import DigitalOutputsModal from "./DigitalOutputsModal";
|
||||
import DigitalOutputsWidget from "./DigitalOutputsWidget";
|
||||
|
||||
import { getDigitalOutputsThunk } from "@/redux/thunks/getDigitalOutputsThunk";
|
||||
import type { DigitalOutput } from "@/types/digitalOutput";
|
||||
|
||||
const DigitalOutputsView: React.FC = () => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const [selectedOutput, setSelectedOutput] = useState<DigitalOutput | null>(
|
||||
null
|
||||
);
|
||||
const [isOutputModalOpen, setIsOutputModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch immediately on mount to ensure data is present without waiting for the first interval
|
||||
dispatch(getDigitalOutputsThunk());
|
||||
|
||||
// Then continue polling periodically
|
||||
const interval = setInterval(() => {
|
||||
dispatch(getDigitalOutputsThunk());
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]);
|
||||
|
||||
const openOutputModal = (output: DigitalOutput) => {
|
||||
setSelectedOutput(output);
|
||||
setIsOutputModalOpen(true);
|
||||
};
|
||||
|
||||
const closeOutputModal = () => {
|
||||
setSelectedOutput(null);
|
||||
setIsOutputModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 h-[calc(100vh-13vh-8vh)] laptop:h-[calc(100vh-10vh-5vh)] xl:h-[calc(100vh-10vh-6vh)] laptop:gap-0">
|
||||
<h1 className="text-base font-semibold mb-2">Schaltausgänge</h1>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 items-start">
|
||||
<DigitalOutputsWidget openOutputModal={openOutputModal} />
|
||||
</div>
|
||||
|
||||
{selectedOutput && (
|
||||
<DigitalOutputsModal
|
||||
selectedOutput={selectedOutput}
|
||||
isOpen={isOutputModalOpen}
|
||||
closeOutputModal={closeOutputModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DigitalOutputsView;
|
||||
@@ -32,9 +32,25 @@ export default function DigitalOutputsWidget({
|
||||
|
||||
try {
|
||||
if (isCPL) {
|
||||
window.location.href = `/CPL?digitalOutputs.html&DAS0${id}=${
|
||||
updatedOutputs[id - 1].status ? 1 : 0
|
||||
}`;
|
||||
// Statt redirect:
|
||||
// window.location.href = `/CPL?...`;
|
||||
|
||||
// Verwende fetch:
|
||||
fetch(
|
||||
`/CPL?digitalOutputs.html&DAS0${id}=${
|
||||
updatedOutputs[id - 1].status ? 1 : 0
|
||||
}`,
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error("Fehler beim Schalten");
|
||||
// Optional: Feedback anzeigen
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("CPL Fehler:", err);
|
||||
});
|
||||
} else {
|
||||
await fetch("/api/cpl/updateDigitalOutputsHandler", {
|
||||
method: "POST",
|
||||
@@ -50,7 +66,7 @@ export default function DigitalOutputsWidget({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow-md border border-gray-200 p-3 rounded-lg w-full h-fit max-h-[400px] overflow-auto">
|
||||
<div className="bg-[var(--color-surface)] text-[var(--color-fg)] shadow-md border border-base p-3 rounded-lg w-full h-fit max-h-[400px] overflow-auto">
|
||||
<h2 className="laptop:text-sm md:text-base 2xl:text-lg font-bold mb-3 flex items-center">
|
||||
<Icon
|
||||
icon={outputIcon}
|
||||
@@ -58,41 +74,60 @@ export default function DigitalOutputsWidget({
|
||||
/>
|
||||
Schaltausgänge
|
||||
</h2>
|
||||
<table className="w-full text-xs laptop:text-[10px] xl:text-xs 2xl:text-sm border-collapse bg-white rounded-lg">
|
||||
<thead className="bg-gray-100 border-b">
|
||||
<table className="w-full text-xs laptop:text-[10px] xl:text-xs 2xl:text-sm border-collapse bg-[var(--color-surface)] rounded-lg">
|
||||
<thead className="bg-[var(--color-surface)] text-[var(--color-fg)] border-b border-base">
|
||||
<tr>
|
||||
<th className="px-1 py-1 text-left">Ausgang</th>
|
||||
<th className="px-1 py-1 text-left">Bezeichnung</th>
|
||||
<th className="px-1 py-1 text-left">Schalter</th>
|
||||
<th className="px-1 py-1 text-left">Aktion</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Ausgang
|
||||
</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Bezeichnung
|
||||
</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Schalter
|
||||
</th>
|
||||
<th className="px-1 py-1 text-left bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
Aktion
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{digitalOutputs.map((output) => (
|
||||
<tr key={output.id} className="border-b">
|
||||
<td className="flex items-center px-1 py-1">
|
||||
<tr
|
||||
key={output.id}
|
||||
className="border-b border-base hover:bg-[var(--color-surface-alt)] transition-colors"
|
||||
>
|
||||
<td className="flex items-center px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
<Icon
|
||||
icon={outputIcon}
|
||||
className="text-gray-600 mr-1 text-base"
|
||||
className="text-[var(--color-muted)] mr-1 text-base"
|
||||
/>
|
||||
{output.id}
|
||||
</td>
|
||||
<td className="px-1 py-1">{output.label}</td>
|
||||
<td className="px-1 py-1">
|
||||
<Icon
|
||||
icon={switchIcon}
|
||||
className={`cursor-pointer text-base transition ${
|
||||
output.status
|
||||
? "text-littwin-blue"
|
||||
: "text-gray-500 scale-x-[-1]"
|
||||
}`}
|
||||
onClick={() => handleToggle(output.id)}
|
||||
/>
|
||||
<td className="px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
{output.label}
|
||||
</td>
|
||||
<td className="px-1 py-1">
|
||||
<td className="px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={output.status}
|
||||
onClick={() => handleToggle(output.id)}
|
||||
className={`relative inline-flex h-4 w-7 items-center rounded-full border border-base transition-colors duration-200 ${
|
||||
output.status ? "bg-littwin-blue" : "bg-base-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0.5 top-1/2 -translate-y-1/2 h-3 w-3 rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
output.status ? "translate-x-3.5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-1 py-1 bg-[var(--color-surface)] text-[var(--color-fg)]">
|
||||
<Icon
|
||||
icon={settingsIcon}
|
||||
className="text-gray-400 text-base cursor-pointer"
|
||||
className="text-[var(--color-muted)] text-base cursor-pointer hover:text-[var(--color-fg)]"
|
||||
onClick={() => openOutputModal(output)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
62
components/main/fall-detection-sensors/FallSensors.tsx
Normal file
62
components/main/fall-detection-sensors/FallSensors.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../../../redux/store";
|
||||
|
||||
// components/main/fall-detection-sensors/FallSensors.tsx
|
||||
interface FallSensorsProps {
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
const FallSensors: React.FC<FallSensorsProps> = ({ slotIndex }) => {
|
||||
const { kvzStatus } = useSelector((state: RootState) => state.kueDataSlice);
|
||||
|
||||
// Nur 4 LEDs für den spezifischen Slot anzeigen
|
||||
const leds = Array.from({ length: 4 }, (_, ledIndex) => {
|
||||
const arrayIndex = slotIndex * 4 + ledIndex;
|
||||
const ledValue = kvzStatus?.[arrayIndex];
|
||||
|
||||
// LED Status: 1 = grün, 0 = rot, 2 = grau, undefined = grau
|
||||
if (ledValue === 1) return "green";
|
||||
if (ledValue === 0) return "red";
|
||||
return "gray"; // für 2 oder undefined
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-gray-300 border border-gray-400 rounded p-1 mt-4 w-full ">
|
||||
{/* Überschrift mit KVZ-Labels */}
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-[8px] font-medium text-gray-700">KVZ1</span>
|
||||
<span className="text-[8px] font-medium text-gray-700">KVZ2</span>
|
||||
<span className="text-[8px] font-medium text-gray-700">KVZ3</span>
|
||||
<span className="text-[8px] font-medium text-gray-700">KVZ4</span>
|
||||
</div>
|
||||
|
||||
{/* LEDs */}
|
||||
<div className="flex justify-between items-center">
|
||||
{leds.map((ledStatus, ledIndex) => {
|
||||
// LED Farben: grün (1), rot (0), grau (2)
|
||||
let bgColor = "bg-gray-400"; // Standard grau
|
||||
let statusText = "Unbekannt";
|
||||
|
||||
if (ledStatus === "green") {
|
||||
bgColor = "bg-green-500";
|
||||
statusText = "Ein";
|
||||
} else if (ledStatus === "red") {
|
||||
bgColor = "bg-red-500";
|
||||
statusText = "Aus";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ledIndex}
|
||||
className={`w-4 h-4 rounded-full border border-gray-500 ${bgColor} flex-shrink-0`}
|
||||
title={`Slot ${slotIndex} LED${ledIndex + 1}: ${statusText}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FallSensors;
|
||||
180
components/main/kabelueberwachung/KabelueberwachungView.tsx
Normal file
180
components/main/kabelueberwachung/KabelueberwachungView.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client"; // /pages/kabelueberwachung.tsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Kue705FO from "@/components/main/kabelueberwachung/kue705FO/Kue705FO";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch } from "@/redux/store"; // Adjust the path to your Redux store file
|
||||
import { RootState } from "@/redux/store"; // Adjust the path to your Redux store file
|
||||
import { getKueDataThunk } from "@/redux/thunks/getKueDataThunk";
|
||||
|
||||
function KabelueberwachungView() {
|
||||
const dispatch: AppDispatch = useDispatch();
|
||||
const searchParams = useSearchParams(); // URL-Parameter holen
|
||||
const initialRack = parseInt(searchParams.get("rack") ?? "1") || 1; // Rack-Nummer aus URL oder 1
|
||||
|
||||
const [activeRack, setActiveRack] = useState<number>(initialRack); // Nutze initialRack als Startwert
|
||||
const [alarmStatus, setAlarmStatus] = useState<boolean[]>([]); // Alarmstatus
|
||||
|
||||
// Redux-Variablen aus dem Store abrufen
|
||||
const {
|
||||
kueOnline,
|
||||
kueID,
|
||||
kueIso,
|
||||
kueAlarm1,
|
||||
kueAlarm2,
|
||||
kueResidence,
|
||||
kueCableBreak,
|
||||
kueGroundFault,
|
||||
} = useSelector((state: RootState) => state.kueDataSlice);
|
||||
|
||||
//----------------------------------------------------------------
|
||||
// Alarmstatus basierend auf Redux-Variablen berechnen
|
||||
const updateAlarmStatus = React.useCallback(() => {
|
||||
const updatedAlarmStatus = kueIso.map(
|
||||
(_: number | string, index: number) => {
|
||||
return Boolean(
|
||||
(kueAlarm1 && kueAlarm1[index]) ||
|
||||
(kueAlarm2 && kueAlarm2[index]) ||
|
||||
(kueCableBreak && kueCableBreak[index]) ||
|
||||
(kueGroundFault && kueGroundFault[index])
|
||||
);
|
||||
}
|
||||
);
|
||||
setAlarmStatus(updatedAlarmStatus);
|
||||
}, [kueIso, kueAlarm1, kueAlarm2, kueCableBreak, kueGroundFault]);
|
||||
|
||||
// Alarmstatus initial berechnen und alle 10 Sekunden aktualisieren
|
||||
useEffect(() => {
|
||||
updateAlarmStatus();
|
||||
const interval = setInterval(updateAlarmStatus, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [updateAlarmStatus]);
|
||||
|
||||
// Modul- und Rack-Daten aufbereiten
|
||||
const allModules = kueIso.map((iso: number | string, index: number) => ({
|
||||
isolationswert: iso,
|
||||
schleifenwiderstand: kueResidence[index],
|
||||
modulName: kueID[index] || `Modul ${index + 1}`, // Eindeutiger Name pro Index
|
||||
kueOnlineStatus: kueOnline[index],
|
||||
alarmStatus: alarmStatus[index],
|
||||
tdrLocation: [], // Placeholder, replace with actual tdrLocation if available
|
||||
win_fallSensorsActive: kueOnline[index] ? 1 : 0, // Beispielwert, anpassen je nach Logik
|
||||
}));
|
||||
//console.log("Alle Module:", allModules);
|
||||
|
||||
const racks = React.useMemo(
|
||||
() => ({
|
||||
rack1: allModules.slice(0, 8),
|
||||
rack2: allModules.slice(8, 16),
|
||||
rack3: allModules.slice(16, 24),
|
||||
rack4: allModules.slice(24, 32),
|
||||
}),
|
||||
[allModules]
|
||||
);
|
||||
|
||||
// Konsolenausgaben für jede Rack-Aufteilung
|
||||
/* console.log(
|
||||
"Rack 1 Module:",
|
||||
racks.rack1.map((slot) => slot.modulName)
|
||||
);
|
||||
console.log(
|
||||
"Rack 2 Module:",
|
||||
racks.rack2.map((slot) => slot.modulName)
|
||||
);
|
||||
console.log(
|
||||
"Rack 3 Module:",
|
||||
racks.rack3.map((slot) => slot.modulName)
|
||||
);
|
||||
console.log(
|
||||
"Rack 4 Module:",
|
||||
racks.rack4.map((slot) => slot.modulName)
|
||||
); */
|
||||
|
||||
// Funktion zum Wechseln des Racks
|
||||
const changeRack = (rack: number) => {
|
||||
setActiveRack(rack);
|
||||
console.log(`Aktives Rack geändert zu: ${rack}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
/* console.log(`Aktives Rack: ${activeRack}`);
|
||||
console.log(
|
||||
`Rack ${activeRack} Modulnamen:`,
|
||||
racks[`rack${activeRack as 1 | 2 | 3 | 4}` as keyof typeof racks].map((slot: any) => slot.modulName)
|
||||
); */
|
||||
}, [activeRack, racks]);
|
||||
|
||||
//-----------------------------------------------------------
|
||||
|
||||
//------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (kueIso.length === 0) {
|
||||
console.log("📦 Lade KUE-Daten aus getKueDataThunk...");
|
||||
dispatch(getKueDataThunk());
|
||||
}
|
||||
}, [dispatch, kueIso.length]);
|
||||
//------------------------------------------------------------
|
||||
|
||||
// JSX rendering
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
{[1, 2, 3, 4].map((rack) => {
|
||||
const isActive = Number(activeRack) === Number(rack);
|
||||
return (
|
||||
<button
|
||||
key={rack}
|
||||
onClick={() => changeRack(rack)}
|
||||
aria-pressed={isActive}
|
||||
className={`mr-2 px-2 py-1 rounded-sm text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-accent/50 ${
|
||||
isActive
|
||||
? "btn-primary"
|
||||
: "btn-muted text-fg opacity-90 hover:opacity-100"
|
||||
}`}
|
||||
>
|
||||
Rack {rack}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex flex-row space-x-8 xl:space-x-0 2xl:space-x-8 qhd:space-x-16 ml-[5%] mt-[5%]">
|
||||
{(
|
||||
racks[
|
||||
`rack${activeRack as 1 | 2 | 3 | 4}` as keyof typeof racks
|
||||
] as typeof allModules
|
||||
).map(
|
||||
(
|
||||
slot: {
|
||||
isolationswert: number | string;
|
||||
schleifenwiderstand: number | string;
|
||||
modulName: string;
|
||||
kueOnlineStatus: number;
|
||||
alarmStatus?: boolean;
|
||||
tdrLocation: number[];
|
||||
win_fallSensorsActive: number;
|
||||
},
|
||||
index: number
|
||||
) => {
|
||||
const slotIndex = index + (activeRack - 1) * 8;
|
||||
return (
|
||||
<div key={index} className="flex">
|
||||
<Kue705FO
|
||||
isolationswert={slot.isolationswert}
|
||||
schleifenwiderstand={slot.schleifenwiderstand}
|
||||
modulName={slot.modulName}
|
||||
kueOnline={slot.kueOnlineStatus}
|
||||
alarmStatus={slot.alarmStatus}
|
||||
slotIndex={slotIndex}
|
||||
tdrLocation={slot.tdrLocation}
|
||||
win_fallSensorsActive={slot.win_fallSensorsActive}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KabelueberwachungView;
|
||||
@@ -1,201 +0,0 @@
|
||||
"use client"; // /components/modules/kue705FO/charts/ChartSwitcher.tsx
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import LoopChartActionBar from "./LoopMeasurementChart/LoopChartActionBar";
|
||||
import LoopMeasurementChart from "./LoopMeasurementChart/LoopMeasurementChart";
|
||||
import TDRChart from "./TDRChart/TDRChart";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { AppDispatch } from "@/redux/store";
|
||||
import { RootState } from "@/redux/store";
|
||||
import {
|
||||
setChartOpen,
|
||||
setFullScreen,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
|
||||
import { resetBrushRange } from "@/redux/slices/brushSlice";
|
||||
import { useLoopChartLoader } from "./LoopMeasurementChart/LoopChartActionBar";
|
||||
|
||||
import {
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
setSelectedMode,
|
||||
setSelectedSlotType,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
|
||||
interface ChartSwitcherProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
const ChartSwitcher: React.FC<ChartSwitcherProps> = ({ isOpen, onClose }) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const chartTitle = useSelector(
|
||||
(state: RootState) => state.loopChartType.chartTitle
|
||||
);
|
||||
|
||||
// **Redux-States für aktive Messkurve (TDR oder Schleife)**
|
||||
const activeMode = useSelector(
|
||||
(state: RootState) => state.kueChartModeSlice.activeMode
|
||||
);
|
||||
const isFullScreen = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice.isFullScreen
|
||||
);
|
||||
|
||||
// **Modal schließen + Redux-Status zurücksetzen**
|
||||
const handleClose = () => {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const toISO = (date: Date) => date.toLocaleDateString("sv-SE");
|
||||
|
||||
// Reset Datum
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
|
||||
// Reset Dropdowns
|
||||
dispatch(setSelectedMode("DIA1"));
|
||||
dispatch(setSelectedSlotType("isolationswiderstand"));
|
||||
|
||||
// Sonstiges Reset
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setFullScreen(false));
|
||||
dispatch(resetBrushRange());
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
// **Vollbildmodus umschalten**
|
||||
const toggleFullScreen = () => {
|
||||
dispatch(setFullScreen(!isFullScreen));
|
||||
};
|
||||
|
||||
// **Slot und Messkurve setzen**
|
||||
// const setChartType = (chartType: "TDR" | "Schleife") => {
|
||||
// dispatch(setSelectedSlot(slotIndex));
|
||||
// dispatch(setSelectedChartType(chartType));
|
||||
// };
|
||||
|
||||
// useLoopChartLoader hook
|
||||
const loadLoopChartData = useLoopChartLoader();
|
||||
|
||||
// Slot number from Redux
|
||||
const slotNumber = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice.slotNumber
|
||||
);
|
||||
|
||||
// immer beim Öffnen das Modal die letzten 30 Tage anzeigen
|
||||
useEffect(() => {
|
||||
if (isOpen && activeMode === "Schleife" && slotNumber !== null) {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const toISO = (date: Date) => date.toLocaleDateString("sv-SE"); // YYYY-MM-DD
|
||||
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
|
||||
// Warten, bis Redux gesetzt ist → dann Daten laden
|
||||
setTimeout(() => {
|
||||
loadLoopChartData.loadLoopChartData();
|
||||
}, 10); // kleiner Delay, damit Redux-State sicher aktualisiert ist
|
||||
}
|
||||
//ESLint ignore
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, activeMode, slotNumber, dispatch]);
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
ariaHideApp={false}
|
||||
style={{
|
||||
overlay: { backgroundColor: "rgba(0, 0, 0, 0.5)" },
|
||||
content: {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
bottom: "auto",
|
||||
marginRight: "-50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: isFullScreen ? "90vw" : "70rem",
|
||||
height: isFullScreen ? "90vh" : "35rem",
|
||||
padding: "1rem",
|
||||
transition: "all 0.3s ease-in-out",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Action-Buttons */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.625rem",
|
||||
right: "0.625rem",
|
||||
display: "flex",
|
||||
gap: "0.75rem",
|
||||
}}
|
||||
>
|
||||
{/* Fullscreen-Button */}
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullScreen ? "bi bi-fullscreen-exit" : "bi bi-arrows-fullscreen"
|
||||
}
|
||||
></i>
|
||||
</button>
|
||||
|
||||
{/* Schließen-Button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-x-circle-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chart-Container */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
{activeMode === "Schleife" ? (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">{chartTitle}</h3>
|
||||
<LoopChartActionBar />
|
||||
<div style={{ flex: 1, height: "90%" }}>
|
||||
<LoopMeasurementChart />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-lg font-semibold">TDR-Messung</h3>
|
||||
<TDRChart isFullScreen={isFullScreen} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ReactModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartSwitcher;
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
// /components/main/kabelueberwachung/kue705FO/Charts/IsoMeasurementChart/IsoChartActionBar.tsx
|
||||
import React, { forwardRef, useImperativeHandle } from "react";
|
||||
import DateRangePicker from "@/components/common/DateRangePicker";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState, useAppDispatch } from "@/redux/store";
|
||||
import {
|
||||
setIsoMeasurementCurveChartData,
|
||||
setSelectedMode,
|
||||
setChartOpen,
|
||||
setLoading,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { setBrushRange } from "@/redux/slices/brushSlice";
|
||||
import { getMessagesThunk } from "@/redux/thunks/getMessagesThunk";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
|
||||
//-----------------------------------------------------------------------------------useIsoChartLoader
|
||||
export const useIsoChartLoader = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { vonDatum, bisDatum, selectedMode, slotNumber } = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice
|
||||
);
|
||||
const hasShownNoDataAlert = React.useRef(false);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const [year, month, day] = dateString.split("-");
|
||||
return `${year};${month};${day}`;
|
||||
};
|
||||
|
||||
const getApiUrl = (mode: "DIA0" | "DIA1" | "DIA2", slotNumber: number) => {
|
||||
const type = 3; // Fest auf Isolationswiderstand gesetzt
|
||||
const typeFolder = "isolationswiderstand";
|
||||
|
||||
let url: string;
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
url = `/api/cpl/slotDataAPIHandler?slot=${slotNumber}&messart=${typeFolder}&dia=${mode}&vonDatum=${vonDatum}&bisDatum=${bisDatum}`;
|
||||
} else {
|
||||
url = `${window.location.origin}/CPL?seite.ACP&${mode}=${formatDate(
|
||||
vonDatum
|
||||
)};${formatDate(bisDatum)};${slotNumber};${type};`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
const loadIsoChartData = async () => {
|
||||
if (slotNumber === null) return;
|
||||
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setIsoMeasurementCurveChartData([]));
|
||||
|
||||
const startTime = Date.now();
|
||||
const MIN_LOADING_TIME_MS = 1000;
|
||||
|
||||
try {
|
||||
const apiUrl = getApiUrl(selectedMode, slotNumber);
|
||||
const response = await fetch(apiUrl);
|
||||
const data = await response.json();
|
||||
|
||||
const waitTime = Math.max(
|
||||
0,
|
||||
MIN_LOADING_TIME_MS - (Date.now() - startTime)
|
||||
);
|
||||
await new Promise((res) => setTimeout(res, waitTime));
|
||||
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
dispatch(setIsoMeasurementCurveChartData(data));
|
||||
dispatch(setChartOpen(true));
|
||||
} else {
|
||||
dispatch(setIsoMeasurementCurveChartData([]));
|
||||
dispatch(setChartOpen(false));
|
||||
if (!hasShownNoDataAlert.current) {
|
||||
alert("⚠️ Keine Messdaten im gewählten Zeitraum gefunden");
|
||||
hasShownNoDataAlert.current = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Laden:", err);
|
||||
alert("❌ Fehler beim Laden.");
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
return { loadIsoChartData };
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------------useIsoDataLoader Hook
|
||||
export const useIsoDataLoader = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { vonDatum, bisDatum, selectedMode, slotNumber } = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice
|
||||
);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const [year, month, day] = dateString.split("-");
|
||||
return `${year};${month};${day}`;
|
||||
};
|
||||
|
||||
const getApiUrl = (mode: "DIA0" | "DIA1" | "DIA2", slotNumber: number) => {
|
||||
const type = 3; // Fest auf Isolationswiderstand gesetzt
|
||||
const typeFolder = "isolationswiderstand";
|
||||
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === "development"
|
||||
? `/api/cpl/slotDataAPIHandler?slot=${slotNumber}&messart=${typeFolder}&dia=${mode}&vonDatum=${vonDatum}&bisDatum=${bisDatum}`
|
||||
: `${window.location.origin}/CPL?seite.ACP&${mode}=${formatDate(
|
||||
vonDatum
|
||||
)};${formatDate(bisDatum)};${slotNumber};${type};`;
|
||||
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
if (slotNumber === null) {
|
||||
console.log("⚠️ Kein Slot ausgewählt - automatisches Laden übersprungen");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = getApiUrl(selectedMode, slotNumber);
|
||||
if (!apiUrl) return;
|
||||
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setIsoMeasurementCurveChartData([]));
|
||||
|
||||
const MIN_LOADING_TIME_MS = 1000;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Fehler: ${response.status}`);
|
||||
|
||||
const jsonData = await response.json();
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
const waitTime = Math.max(0, MIN_LOADING_TIME_MS - elapsedTime);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
if (Array.isArray(jsonData) && jsonData.length > 0) {
|
||||
dispatch(setIsoMeasurementCurveChartData(jsonData));
|
||||
dispatch(setChartOpen(true));
|
||||
} else {
|
||||
dispatch(setIsoMeasurementCurveChartData([]));
|
||||
dispatch(setChartOpen(false));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim automatischen Laden der Daten:", err);
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
return { loadData };
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------------IsoChartActionBar
|
||||
const IsoChartActionBar = forwardRef((_props, ref) => {
|
||||
IsoChartActionBar.displayName = "IsoChartActionBar";
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { vonDatum, bisDatum, selectedMode, slotNumber, chartTitle } =
|
||||
useSelector((state: RootState) => state.kabelueberwachungChartSlice);
|
||||
// Aus DateRangePicker-Slice kommen die Werte, die der User im UI wählt
|
||||
const { vonDatum: pickerVonDatum, bisDatum: pickerBisDatum } = useSelector(
|
||||
(state: RootState) => state.dateRangePicker
|
||||
);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const [year, month, day] = dateString.split("-");
|
||||
return `${year};${month};${day}`;
|
||||
};
|
||||
|
||||
const getApiUrl = (
|
||||
mode: "DIA0" | "DIA1" | "DIA2",
|
||||
slotNumber: number,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
) => {
|
||||
const type = 3; // Fest auf Isolationswiderstand gesetzt
|
||||
const typeFolder = "isolationswiderstand";
|
||||
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === "development"
|
||||
? `/api/cpl/slotDataAPIHandler?slot=${slotNumber}&messart=${typeFolder}&dia=${mode}&vonDatum=${fromDate}&bisDatum=${toDate}`
|
||||
: `${window.location.origin}/CPL?seite.ACP&${mode}=${formatDate(
|
||||
fromDate
|
||||
)};${formatDate(toDate)};${slotNumber};${type};`;
|
||||
|
||||
console.log("baseUrl", baseUrl);
|
||||
return baseUrl;
|
||||
};
|
||||
|
||||
const handleFetchData = async () => {
|
||||
if (slotNumber === null) {
|
||||
alert("⚠️ Bitte zuerst einen KÜ auswählen!");
|
||||
return;
|
||||
}
|
||||
// Wenn Meldungen-Ansicht aktiv ist, dann Meldungen laden
|
||||
if (chartTitle === "Meldungen") {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
const fromDate = pickerVonDatum ?? vonDatum;
|
||||
const toDate = pickerBisDatum ?? bisDatum;
|
||||
await dispatch(getMessagesThunk({ fromDate, toDate })).unwrap();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("❌ Fehler beim Laden der Meldungen:", message);
|
||||
alert("❌ Fehler beim Laden der Meldungen.");
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Messkurve (ISO) laden
|
||||
const fromDate = pickerVonDatum ?? vonDatum;
|
||||
const toDate = pickerBisDatum ?? bisDatum;
|
||||
const apiUrl = getApiUrl(selectedMode, slotNumber, fromDate, toDate);
|
||||
if (!apiUrl) return;
|
||||
|
||||
dispatch(setLoading(true));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setIsoMeasurementCurveChartData([]));
|
||||
|
||||
const MIN_LOADING_TIME_MS = 1000;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Fehler: ${response.status}`);
|
||||
|
||||
const jsonData = await response.json();
|
||||
const elapsedTime = Date.now() - startTime;
|
||||
const waitTime = Math.max(0, MIN_LOADING_TIME_MS - elapsedTime);
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
console.log("▶️ Lade Isolationswiderstand-Daten für:");
|
||||
console.log(" Slot:", slotNumber);
|
||||
console.log(" Modus:", selectedMode);
|
||||
console.log(" Von:", fromDate);
|
||||
console.log(" Bis:", toDate);
|
||||
console.log(" URL:", apiUrl);
|
||||
console.log(" Daten:", jsonData);
|
||||
|
||||
if (Array.isArray(jsonData) && jsonData.length > 0) {
|
||||
dispatch(setIsoMeasurementCurveChartData(jsonData));
|
||||
dispatch(setChartOpen(true));
|
||||
} else {
|
||||
alert("⚠️ Keine Messdaten im gewählten Zeitraum gefunden.");
|
||||
dispatch(setIsoMeasurementCurveChartData([]));
|
||||
dispatch(setChartOpen(false));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("❌ Fehler beim Laden der Daten:", message);
|
||||
alert("❌ Fehler beim Laden der Daten.");
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({ handleFetchData }));
|
||||
|
||||
const isMeldungen = chartTitle === "Meldungen";
|
||||
|
||||
return (
|
||||
<div className="toolbar w-full justify-between flex-wrap">
|
||||
<div className="flex items-center gap-2 pr-4">
|
||||
<span className=" font-semibold uppercase tracking-wide text-muted">
|
||||
KÜ
|
||||
</span>
|
||||
<span className=" font-medium px-2 py-0.5 rounded bg-surface-alt border border-base min-w-[3rem] text-center">
|
||||
{slotNumber !== null ? slotNumber + 1 : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-1 justify-end">
|
||||
{/* Always show date range; requirement: in Meldungen only Von/Bis + Anzeigen */}
|
||||
<DateRangePicker />
|
||||
{!isMeldungen && (
|
||||
<>
|
||||
<Listbox
|
||||
value={selectedMode}
|
||||
onChange={(value) => {
|
||||
dispatch(setSelectedMode(value));
|
||||
dispatch(setBrushRange({ startIndex: 0, endIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
<div className="relative w-48">
|
||||
<Listbox.Button className="dropdown-surface w-full flex items-center justify-between">
|
||||
<span className="dropdown-text-fix">
|
||||
{
|
||||
{
|
||||
DIA0: "Alle Messwerte",
|
||||
DIA1: "Stündlich",
|
||||
DIA2: "Täglich",
|
||||
}[selectedMode]
|
||||
}
|
||||
</span>
|
||||
<i className="bi bi-chevron-down opacity-70" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="dropdown-options absolute z-50 mt-1 w-full max-h-60 overflow-auto">
|
||||
{["DIA0", "DIA1", "DIA2"].map((mode) => (
|
||||
<Listbox.Option
|
||||
key={mode}
|
||||
value={mode}
|
||||
className={({ selected, active }) =>
|
||||
`px-3 py-1.5 cursor-pointer rounded-sm m-0.5 ${
|
||||
selected
|
||||
? "dropdown-option-active"
|
||||
: active
|
||||
? "dropdown-option-hover"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{
|
||||
{
|
||||
DIA0: "Alle Messwerte",
|
||||
DIA1: "Stündlich",
|
||||
DIA2: "Täglich",
|
||||
}[mode as "DIA0" | "DIA1" | "DIA2"]
|
||||
}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
className="btn-primary h-8 font-medium px-3"
|
||||
type="button"
|
||||
>
|
||||
Daten laden
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isMeldungen && (
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
className="btn-primary h-8 font-medium px-4"
|
||||
type="button"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default IsoChartActionBar;
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import ReactModal from "react-modal";
|
||||
import IsoMeasurementChart from "./IsoMeasurementChart";
|
||||
import IsoChartActionBar from "./IsoChartActionBar";
|
||||
import Report from "./Report";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { AppDispatch, RootState } from "@/redux/store";
|
||||
import {
|
||||
setChartOpen,
|
||||
setFullScreen,
|
||||
setSlotNumber,
|
||||
setChartTitle,
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
setSelectedMode,
|
||||
setSelectedSlotType,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { resetBrushRange } from "@/redux/slices/brushSlice";
|
||||
import { resetDateRange } from "@/redux/slices/dateRangePickerSlice";
|
||||
|
||||
interface IsoChartViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
type ActionBarRefType = { handleFetchData: () => void };
|
||||
|
||||
const IsoChartView: React.FC<IsoChartViewProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
slotIndex,
|
||||
}) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const { isFullScreen, chartTitle } = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice
|
||||
);
|
||||
|
||||
const actionBarRef = useRef<ActionBarRefType>(null);
|
||||
|
||||
const initDates = () => {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (d: Date) => d.toLocaleDateString("sv-SE");
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
initDates();
|
||||
dispatch(resetDateRange());
|
||||
dispatch(setSelectedMode("DIA0"));
|
||||
dispatch(setSelectedSlotType("isolationswiderstand"));
|
||||
dispatch(setChartTitle("Messkurve"));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setFullScreen(false));
|
||||
dispatch(resetBrushRange());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => dispatch(setFullScreen(!isFullScreen));
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
dispatch(setSlotNumber(slotIndex));
|
||||
// inline initDates to avoid extra dependency
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (d: Date) => d.toLocaleDateString("sv-SE");
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
dispatch(setSelectedSlotType("isolationswiderstand"));
|
||||
dispatch(setSelectedMode("DIA0"));
|
||||
dispatch(setChartTitle("Messkurve"));
|
||||
const t = setTimeout(() => actionBarRef.current?.handleFetchData(), 120);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen, slotIndex, dispatch]);
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
ariaHideApp={false}
|
||||
style={{
|
||||
overlay: {
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
backdropFilter: "blur(2px)",
|
||||
},
|
||||
content: {
|
||||
inset: "50% auto auto 50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: isFullScreen ? "90vw" : "72rem",
|
||||
height: isFullScreen ? "90vh" : "38rem",
|
||||
padding: 0,
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
contentLabel="Isolationswiderstand"
|
||||
>
|
||||
<header className="modal-header relative pr-56">
|
||||
<h3 className="text-sm font-semibold tracking-wide">
|
||||
Isolationswiderstand
|
||||
</h3>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="icon-btn"
|
||||
aria-label={isFullScreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullScreen
|
||||
? "bi bi-fullscreen-exit"
|
||||
: "bi bi-arrows-fullscreen"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="icon-btn"
|
||||
aria-label="Schließen"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="bi bi-x-circle-fill"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-28">
|
||||
<Listbox
|
||||
value={chartTitle}
|
||||
onChange={(value: "Messkurve" | "Meldungen") =>
|
||||
dispatch(setChartTitle(value))
|
||||
}
|
||||
>
|
||||
<div className="relative w-40">
|
||||
<Listbox.Button className="dropdown-surface w-full flex items-center justify-between h-8">
|
||||
<span className="dropdown-text-fix">{chartTitle}</span>
|
||||
<i className="bi bi-chevron-down text-sm opacity-70" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="dropdown-options absolute z-50 mt-1 w-full max-h-60 overflow-auto text-sm">
|
||||
{(["Messkurve", "Meldungen"] as const).map((option) => (
|
||||
<Listbox.Option
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ selected, active }) =>
|
||||
`px-3 py-1.5 cursor-pointer rounded-sm m-0.5 ${
|
||||
selected
|
||||
? "dropdown-option-active"
|
||||
: active
|
||||
? "dropdown-option-hover"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col flex-1 p-3 gap-3">
|
||||
<IsoChartActionBar ref={actionBarRef} />
|
||||
<div className="flex-1 relative">
|
||||
{chartTitle === "Messkurve" ? (
|
||||
<IsoMeasurementChart />
|
||||
) : (
|
||||
<Report moduleType="ISO" autoLoad={chartTitle === "Meldungen"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ReactModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default IsoChartView;
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
interface IsoCustomTooltipProps {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
dataKey: string;
|
||||
value: number;
|
||||
name?: string;
|
||||
color?: string;
|
||||
unit?: string;
|
||||
// Add other known properties here as needed
|
||||
}>;
|
||||
label?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
const IsoCustomTooltip: React.FC<IsoCustomTooltipProps> = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
unit,
|
||||
}) => {
|
||||
if (active && payload && payload.length) {
|
||||
const messwertMax = payload.find((p) => p.dataKey === "messwertMaximum");
|
||||
const messwert = payload.find((p) => p.dataKey === "messwert");
|
||||
const messwertMin = payload.find((p) => p.dataKey === "messwertMinimum");
|
||||
const messwertDurchschnitt = payload.find(
|
||||
(p) => p.dataKey === "messwertDurchschnitt"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "8px",
|
||||
border: "1px solid lightgrey",
|
||||
borderRadius: "5px",
|
||||
}}
|
||||
>
|
||||
<strong>{new Date(label as string).toLocaleString()}</strong>
|
||||
{messwertMax && (
|
||||
<div style={{ color: "grey" }}>
|
||||
Messwert Maximum: {messwertMax.value.toFixed(2)} {unit}
|
||||
</div>
|
||||
)}
|
||||
{messwert && (
|
||||
<div style={{ color: "#00AEEF", fontWeight: "bold" }}>
|
||||
Messwert: {messwert.value.toFixed(2)} {unit}
|
||||
</div>
|
||||
)}
|
||||
{messwertDurchschnitt && (
|
||||
<div style={{ color: "#00AEEF" }}>
|
||||
Messwert Durchschnitt: {messwertDurchschnitt.value.toFixed(2)}{" "}
|
||||
{unit}
|
||||
</div>
|
||||
)}
|
||||
{messwertMin && (
|
||||
<div style={{ color: "grey" }}>
|
||||
Messwert Minimum: {messwertMin.value.toFixed(2)} {unit}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default IsoCustomTooltip;
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client"; // /components/main/kabelueberwachung/kue705FO/Charts/IsoMeasurementChart/IsoMeasurementChart.tsx
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from "chart.js";
|
||||
|
||||
import "chartjs-adapter-date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
import { differenceInHours, parseISO } from "date-fns";
|
||||
|
||||
ChartJS.register(
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
import { getColor } from "@/utils/colors";
|
||||
import { PulseLoader } from "react-spinners";
|
||||
|
||||
type IsoMeasurementEntry = {
|
||||
t: string;
|
||||
i: number;
|
||||
m: number;
|
||||
g: number;
|
||||
a: number;
|
||||
};
|
||||
|
||||
const usePreviousData = (data: IsoMeasurementEntry[]) => {
|
||||
const ref = useRef<IsoMeasurementEntry[]>([]);
|
||||
useEffect(() => {
|
||||
ref.current = data;
|
||||
}, [data]);
|
||||
return ref.current;
|
||||
};
|
||||
|
||||
const IsoMeasurementChart = () => {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const chartInstance = useRef<ChartJS | null>(null);
|
||||
|
||||
const {
|
||||
isoMeasurementCurveChartData,
|
||||
selectedMode,
|
||||
unit,
|
||||
isFullScreen,
|
||||
isLoading,
|
||||
vonDatum,
|
||||
bisDatum,
|
||||
} = useSelector((state: RootState) => state.kabelueberwachungChartSlice);
|
||||
|
||||
const previousData = usePreviousData(isoMeasurementCurveChartData);
|
||||
|
||||
// Vergleichsfunktion
|
||||
const isEqual = (
|
||||
a: IsoMeasurementEntry[],
|
||||
b: IsoMeasurementEntry[]
|
||||
): boolean => {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (
|
||||
a[i].t !== b[i].t ||
|
||||
a[i].i !== b[i].i ||
|
||||
a[i].m !== b[i].m ||
|
||||
a[i].g !== b[i].g ||
|
||||
a[i].a !== b[i].a
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// ⏱ Zeitspanne in Stunden berechnen
|
||||
const from = vonDatum ? parseISO(vonDatum) : null;
|
||||
const to = bisDatum ? parseISO(bisDatum) : null;
|
||||
const durationInHours = from && to ? differenceInHours(to, from) : 9999;
|
||||
const timeUnit: "hour" | "day" = durationInHours <= 48 ? "hour" : "day";
|
||||
|
||||
useEffect(() => {
|
||||
import("chartjs-plugin-zoom").then((zoomPlugin) => {
|
||||
if (!ChartJS.registry.plugins.get("zoom")) {
|
||||
ChartJS.register(zoomPlugin.default);
|
||||
}
|
||||
|
||||
if (!canvasRef.current) return;
|
||||
const ctx = canvasRef.current.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
if (isEqual(isoMeasurementCurveChartData, previousData)) {
|
||||
return; // keine echte Datenänderung → nicht neu zeichnen
|
||||
}
|
||||
|
||||
if (chartInstance.current) {
|
||||
chartInstance.current.destroy();
|
||||
}
|
||||
|
||||
const chartData = {
|
||||
labels: isoMeasurementCurveChartData
|
||||
.map((entry) => new Date(entry.t))
|
||||
.reverse(),
|
||||
datasets: [
|
||||
{
|
||||
label: "Messwert Minimum",
|
||||
data: isoMeasurementCurveChartData.map((e) => e.i).reverse(),
|
||||
borderColor: "lightgrey",
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
label: "Messwert Maximum",
|
||||
data: isoMeasurementCurveChartData.map((e) => e.a).reverse(),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 3,
|
||||
},
|
||||
selectedMode === "DIA0"
|
||||
? {
|
||||
label: "Messwert",
|
||||
data: isoMeasurementCurveChartData.map((e) => e.m).reverse(),
|
||||
borderColor: getColor("littwin-blue"),
|
||||
backgroundColor: "rgba(59,130,246,0.5)",
|
||||
borderWidth: 3,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 2,
|
||||
}
|
||||
: {
|
||||
label: "Messwert Durchschnitt",
|
||||
data: isoMeasurementCurveChartData.map((e) => e.g).reverse(),
|
||||
borderColor: getColor("littwin-blue"),
|
||||
backgroundColor: "rgba(59,130,246,0.5)",
|
||||
borderWidth: 3,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: "top" as const },
|
||||
tooltip: {
|
||||
yAlign: "bottom" as const,
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
},
|
||||
zoom: {
|
||||
pan: { enabled: true, mode: "x" as const },
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: "x" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time" as const,
|
||||
time: {
|
||||
unit: timeUnit,
|
||||
tooltipFormat: "dd.MM.yyyy HH:mm:ss",
|
||||
displayFormats: {
|
||||
hour: "HH:mm",
|
||||
day: "dd.MM.",
|
||||
},
|
||||
locale: de,
|
||||
},
|
||||
title: { display: true, text: "Zeit" },
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: unit },
|
||||
ticks: { precision: 0 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
chartInstance.current = new ChartJS(ctx, {
|
||||
type: "line",
|
||||
data: chartData,
|
||||
options,
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isoMeasurementCurveChartData, selectedMode, vonDatum, bisDatum]);
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: isFullScreen ? "90%" : "400px" }}>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<PulseLoader color="#3B82F6" />
|
||||
</div>
|
||||
)}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: isLoading ? "none" : "block",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IsoMeasurementChart;
|
||||
@@ -0,0 +1,54 @@
|
||||
### 🧭 Zoom-Verhalten beim Schleifen-/Isolationsdiagramm
|
||||
|
||||
In dieser Komponente wird das automatische Nachladen der Messwerte temporär deaktiviert, wenn der Benutzer per Maus in das Diagramm zoomt oder pannt. Nach 30 Sekunden ohne Zoom/Pan-Aktion wird die automatische Aktualisierung wieder aktiviert. Dieses Verhalten dient dazu, den Zoom-Zustand nicht durch neue Daten zu verlieren.
|
||||
|
||||
---
|
||||
|
||||
### 📁 Enthaltene Komponenten
|
||||
|
||||
- `LoopChartActionBar.tsx`
|
||||
→ Auswahlleiste für Slot-Nummer, Zeitraum (über `DateRangePicker`), Messmodus (`DIA0`, `DIA1`, `DIA2`) und Slot-Typ (Schleife/Isolation).
|
||||
→ Ruft alle 10 Sekunden neue Messdaten ab – außer der Zoom-Modus pausiert das.
|
||||
- `LoopMeasurementChart.tsx`
|
||||
→ Das eigentliche Liniendiagramm mit Chart.js + Zoom-Plugin.
|
||||
→ Erkennt Zoom/Pan und setzt `chartUpdatePaused`, bis 30 Sekunden Inaktivität vergangen sind.
|
||||
|
||||
- `DateRangePicker.tsx`
|
||||
→ Zeigt zwei Felder für Von-/Bis-Datum. Nutzt Redux, um globale Zeitfenster zu setzen.
|
||||
|
||||
- `CustomTooltip.tsx`
|
||||
→ Zeigt beim Hover über die Kurve kontextbezogene Werte wie Messwert, Min, Max und Durchschnitt (DIA0/1/2).
|
||||
|
||||
---
|
||||
|
||||
### 🟢 UML-Aktivitätsdiagramm (Zoom → Pause → Timer → Auto-Update)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start([Start])
|
||||
ZoomEvent[[Zoom oder Pan erkannt]]
|
||||
SetPause[Setze chartUpdatePaused = true]
|
||||
StartTimer[Starte 30s Timer]
|
||||
Check[Timer abgelaufen?]
|
||||
SetResume[Setze chartUpdatePaused = false]
|
||||
FetchData[[Datenabruf wieder erlaubt]]
|
||||
End([Ende])
|
||||
|
||||
Start --> ZoomEvent --> SetPause --> StartTimer --> Check
|
||||
Check -- Nein --> StartTimer
|
||||
Check -- Ja --> SetResume --> FetchData --> End
|
||||
```
|
||||
|
||||
stateDiagram-v2
|
||||
[*] --> AktualisierungAktiv
|
||||
|
||||
AktualisierungAktiv --> ZoomPause : Zoom/Pan erkannt
|
||||
ZoomPause --> AktualisierungAktiv : 30 Sekunden Inaktivität
|
||||
|
||||
state AktualisierungAktiv {
|
||||
[*] --> Normalbetrieb
|
||||
}
|
||||
|
||||
state ZoomPause {
|
||||
[*] --> CountdownLäuft
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"; // Report.tsx
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState, AppDispatch } from "@/redux/store";
|
||||
import { getMessagesThunk } from "@/redux/thunks/getMessagesThunk";
|
||||
|
||||
// Gleiche Datenstruktur wie MeldungenView
|
||||
type Meldung = {
|
||||
t: string; // timestamp
|
||||
s: number; // status/priority
|
||||
c: string; // color
|
||||
m: string; // message
|
||||
i: string; // source/info
|
||||
v: string; // value/status text
|
||||
};
|
||||
|
||||
type ModuleType = "ISO" | "TDR" | "RSL" | "KVZ";
|
||||
|
||||
interface ReportProps {
|
||||
moduleType: ModuleType;
|
||||
autoLoad?: boolean;
|
||||
}
|
||||
|
||||
const Report: React.FC<ReportProps> = ({ moduleType, autoLoad = true }) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filteredMessages, setFilteredMessages] = useState<Meldung[]>([]);
|
||||
|
||||
const { vonDatum, bisDatum, slotNumber } = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice
|
||||
);
|
||||
|
||||
// Nachrichten aus dem globalen Store
|
||||
const messages = useSelector((state: RootState) => state.messages.data);
|
||||
|
||||
// Nachrichten für den aktuellen Slot filtern
|
||||
const filterMessagesForSlot = useCallback(
|
||||
(allMessages: Meldung[], slot: number) => {
|
||||
if (slot === null) return [];
|
||||
|
||||
// Primärer Filter: Exakte CableLineX Übereinstimmung (X = slot + 1)
|
||||
const primaryIdentifier = `CableLine${slot + 1}`;
|
||||
|
||||
console.log(
|
||||
`🔍 Filtere Nachrichten für Slot ${slot} (${primaryIdentifier}):`
|
||||
);
|
||||
console.log(`📥 Gesamt Nachrichten: ${allMessages.length}`);
|
||||
|
||||
// Debug: Zeige alle verfügbaren Quellen
|
||||
const allSources = [...new Set(allMessages.map((msg) => msg.i))];
|
||||
console.log(`📋 Alle verfügbaren Quellen:`, allSources);
|
||||
|
||||
// Filter basierend auf der Quelle (i-Feld) - EXAKTE Übereinstimmung
|
||||
const filtered = allMessages.filter((msg: Meldung) => {
|
||||
// Exakte Übereinstimmung: msg.i sollte genau "CableLineX" sein
|
||||
const isExactMatch = msg.i === primaryIdentifier;
|
||||
|
||||
// Fallback: Falls die Quelle mehr Informationen enthält (z.B. "CableLine1_Sensor")
|
||||
const isPartialMatch =
|
||||
msg.i.startsWith(primaryIdentifier) &&
|
||||
(msg.i === primaryIdentifier ||
|
||||
msg.i.charAt(primaryIdentifier.length).match(/[^0-9]/));
|
||||
|
||||
const isMatch = isExactMatch || isPartialMatch;
|
||||
|
||||
if (isMatch) {
|
||||
console.log(`✅ Gefunden: "${msg.i}" -> ${msg.m}`);
|
||||
}
|
||||
return isMatch;
|
||||
});
|
||||
|
||||
console.log(
|
||||
`📤 Gefilterte Nachrichten für ${primaryIdentifier}: ${filtered.length}`
|
||||
);
|
||||
|
||||
// Falls keine Nachrichten mit CableLineX gefunden, versuche alternative Identifikatoren
|
||||
if (filtered.length === 0) {
|
||||
console.log(
|
||||
`⚠️ Keine Nachrichten für ${primaryIdentifier} gefunden. Versuche alternative Identifikatoren...`
|
||||
);
|
||||
|
||||
const alternativeIdentifiers = [
|
||||
`Slot${slot + 1}`,
|
||||
`KÜ${slot + 1}`,
|
||||
`Kue${slot + 1}`,
|
||||
`Cable${slot + 1}`,
|
||||
`Line${slot + 1}`,
|
||||
];
|
||||
|
||||
const alternativeFiltered = allMessages.filter((msg: Meldung) => {
|
||||
return alternativeIdentifiers.some((identifier) => {
|
||||
const isExactMatch = msg.i === identifier;
|
||||
const isPartialMatch =
|
||||
msg.i.startsWith(identifier) &&
|
||||
(msg.i === identifier ||
|
||||
msg.i.charAt(identifier.length).match(/[^0-9]/));
|
||||
const isMatch = isExactMatch || isPartialMatch;
|
||||
|
||||
if (isMatch) {
|
||||
console.log(`🔄 Alternative gefunden: "${msg.i}" -> ${msg.m}`);
|
||||
}
|
||||
return isMatch;
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`📤 Alternative gefilterte Nachrichten: ${alternativeFiltered.length}`
|
||||
);
|
||||
return alternativeFiltered;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Modul-spezifische Schlüsselwörter (alle lowercase, ö => oe normalisiert)
|
||||
const moduleKeywordMap = useMemo<Record<ModuleType, string[]>>(
|
||||
() => ({
|
||||
ISO: [
|
||||
"modul online",
|
||||
"aderbruch",
|
||||
"erdschluss",
|
||||
"isofehler",
|
||||
"iso fehler",
|
||||
"iso-fehler",
|
||||
"isolationsfehler",
|
||||
"isolationfehler",
|
||||
"isolation fehler",
|
||||
],
|
||||
TDR: ["modul online", "tdr aktiv", "tdr entfernung"],
|
||||
RSL: ["modul online", "aderbruch", "schleifenfehler"],
|
||||
KVZ: ["modul online", "aderbruch", "kvz störung", "kvz stoerung"],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const normalize = (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ü/g, "ue");
|
||||
|
||||
// Daten laden
|
||||
const loadMessages = useCallback(async () => {
|
||||
if (slotNumber === null) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Redux Thunk verwenden (wie in MeldungenView)
|
||||
await dispatch(
|
||||
getMessagesThunk({
|
||||
fromDate: vonDatum,
|
||||
toDate: bisDatum,
|
||||
})
|
||||
).unwrap();
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Laden der Berichte:", err);
|
||||
setError("Fehler beim Laden der Meldungen.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dispatch, vonDatum, bisDatum, slotNumber]);
|
||||
|
||||
// Filter anwenden wenn sich Nachrichten oder Slot ändern
|
||||
useEffect(() => {
|
||||
if (slotNumber !== null && messages.length > 0) {
|
||||
const slotFiltered = filterMessagesForSlot(messages, slotNumber);
|
||||
// Modul-Filter anwenden
|
||||
const keywords = moduleKeywordMap[moduleType].map(normalize);
|
||||
const moduleFiltered = slotFiltered.filter((m) => {
|
||||
const msgNorm = normalize(m.m);
|
||||
return keywords.some((kw) => msgNorm.includes(kw));
|
||||
});
|
||||
// Fallback: Wenn keine Keyword-Treffer, zeige Slot-Filter-Ergebnis
|
||||
setFilteredMessages(
|
||||
moduleFiltered.length > 0 ? moduleFiltered : slotFiltered
|
||||
);
|
||||
} else {
|
||||
setFilteredMessages([]);
|
||||
}
|
||||
}, [
|
||||
messages,
|
||||
slotNumber,
|
||||
filterMessagesForSlot,
|
||||
moduleType,
|
||||
moduleKeywordMap,
|
||||
]);
|
||||
|
||||
// Automatisches Laden beim Mount und bei Änderungen (optional)
|
||||
useEffect(() => {
|
||||
if (!autoLoad) return;
|
||||
if (slotNumber !== null) {
|
||||
loadMessages();
|
||||
}
|
||||
}, [loadMessages, slotNumber, autoLoad]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-t-2 border-blue-500 rounded-full animate-spin" />
|
||||
<span>Lade Meldungen...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-center text-red-500 p-4">{error}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col p-4">
|
||||
{filteredMessages.length === 0 ? (
|
||||
<div className="text-center text-gray-500 ">
|
||||
Keine Meldungen für CableLine
|
||||
{slotNumber !== null ? slotNumber + 1 : "-"} (Filter: {moduleType}) im
|
||||
gewählten Zeitraum gefunden.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto table-scroll-region">
|
||||
<div className="data-table-wrapper">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "60px" }}>Prio</th>
|
||||
<th style={{ minWidth: "180px" }}>Zeitstempel</th>
|
||||
<th style={{ minWidth: "140px" }}>Quelle</th>
|
||||
<th style={{ minWidth: "260px" }}>Meldung</th>
|
||||
<th style={{ minWidth: "120px" }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMessages.map((msg, index) => (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<div
|
||||
className="prio-dot"
|
||||
style={{ backgroundColor: msg.c }}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(msg.t).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td>{msg.i}</td>
|
||||
<td className="truncate max-w-[22ch]" title={msg.m}>
|
||||
{msg.m}
|
||||
</td>
|
||||
<td>{msg.v}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* <div className="mt-4 text-sm text-gray-500 text-center mt-4">
|
||||
{filteredMessages.length} Meldung(en) (Filter: {moduleType}) gefunden
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Report;
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client"; // KVZChartView.tsx
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import DateRangePicker from "@/components/common/DateRangePicker";
|
||||
import { getMessagesThunk } from "@/redux/thunks/getMessagesThunk";
|
||||
import { setLoading } from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import ReactModal from "react-modal";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch, RootState } from "@/redux/store";
|
||||
import {
|
||||
setChartOpen,
|
||||
setFullScreen,
|
||||
setSlotNumber,
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
setSelectedMode,
|
||||
setSelectedSlotType,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { resetBrushRange } from "@/redux/slices/brushSlice";
|
||||
import FallSensors from "../../../../fall-detection-sensors/FallSensors";
|
||||
import Report from "../IsoMeasurementChart/Report";
|
||||
|
||||
interface KVZChartViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
// Modal zur Anzeige der KVz Zustände (Sturzsensoren / Fall Detection LEDs)
|
||||
// Stil und Verhalten analog zu ISO / RSL / TDR Modals
|
||||
const KVZChartView: React.FC<KVZChartViewProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
slotIndex,
|
||||
}) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const { isFullScreen, slotNumber, vonDatum, bisDatum } = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice
|
||||
);
|
||||
const { vonDatum: pickerVonDatum, bisDatum: pickerBisDatum } = useSelector(
|
||||
(state: RootState) => state.dateRangePicker
|
||||
);
|
||||
|
||||
// Beim Öffnen: Slot + Standard-Datumsbereich setzen (30 Tage) – analog zu anderen Modals
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
dispatch(setSlotNumber(slotIndex));
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (d: Date) => d.toLocaleDateString("sv-SE");
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
}, [isOpen, slotIndex, dispatch]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset auf Default (wie andere Modals es tun)
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (d: Date) => d.toLocaleDateString("sv-SE");
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
dispatch(setSelectedMode("DIA1"));
|
||||
dispatch(setSelectedSlotType("isolationswiderstand"));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setFullScreen(false));
|
||||
dispatch(resetBrushRange());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => dispatch(setFullScreen(!isFullScreen));
|
||||
|
||||
const handleFetchMessages = async () => {
|
||||
const fromDate = pickerVonDatum ?? vonDatum;
|
||||
const toDate = pickerBisDatum ?? bisDatum;
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
await dispatch(getMessagesThunk({ fromDate, toDate })).unwrap();
|
||||
} catch (e) {
|
||||
console.error("Fehler beim Laden der KVZ Meldungen", e);
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
ariaHideApp={false}
|
||||
style={{
|
||||
overlay: {
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
backdropFilter: "blur(2px)",
|
||||
},
|
||||
content: {
|
||||
inset: "50% auto auto 50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: isFullScreen ? "90vw" : "72rem",
|
||||
height: isFullScreen ? "90vh" : "38rem",
|
||||
padding: 0,
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
contentLabel="KVZ Zustände & Meldungen"
|
||||
>
|
||||
<header className="modal-header relative pr-32">
|
||||
<h3 className="text-sm font-semibold tracking-wide">
|
||||
KVZ Zustände & Meldungen
|
||||
</h3>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="icon-btn"
|
||||
aria-label={isFullScreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullScreen
|
||||
? "bi bi-fullscreen-exit"
|
||||
: "bi bi-arrows-fullscreen"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="icon-btn"
|
||||
aria-label="Schließen"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="bi bi-x-circle-fill"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col flex-1 p-3 gap-3">
|
||||
{/* Toolbar */}
|
||||
<div className="w-full flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold opacity-80 select-none text-fg-secondary">
|
||||
KÜ {slotNumber !== null ? slotNumber + 1 : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-[20rem]">
|
||||
<div className="relative z-[1500]">
|
||||
<DateRangePicker />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchMessages}
|
||||
className="btn-primary h-8 font-medium px-4"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-end w-48">
|
||||
<FallSensors slotIndex={slotIndex} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 relative border border-base rounded bg-[var(--color-surface-alt)] text-fg overflow-hidden p-2">
|
||||
<div className="w-full h-full rounded bg-[var(--color-surface)] overflow-hidden">
|
||||
<Report moduleType="KVZ" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ReactModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default KVZChartView;
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
// /components/main/kabelueberwachung/kue705FO/Charts/LoopMeasurementChart/LoopChartActionBar.tsx
|
||||
import React from "react";
|
||||
import DateRangePicker from "./DateRangePicker";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { useAppDispatch } from "@/redux/store";
|
||||
import { getMessagesThunk } from "@/redux/thunks/getMessagesThunk";
|
||||
import { RSL_DURATION_SECONDS, NODE_ENV } from "@/utils/env";
|
||||
import DateRangePicker from "@/components/common/DateRangePicker";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import {
|
||||
setLoopMeasurementCurveChartData,
|
||||
setSelectedMode,
|
||||
setSelectedSlotType,
|
||||
setChartOpen,
|
||||
setLoading,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { setBrushRange } from "@/redux/slices/brushSlice";
|
||||
import { setChartTitle } from "@/redux/slices/loopChartTypeSlice";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
|
||||
//-----------------------------------------------------------------------------------useLoopChartLoader
|
||||
export const useLoopChartLoader = () => {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useAppDispatch();
|
||||
const { vonDatum, bisDatum, selectedMode, selectedSlotType, slotNumber } =
|
||||
useSelector((state: RootState) => state.kabelueberwachungChartSlice);
|
||||
const hasShownNoDataAlert = React.useRef(false);
|
||||
@@ -30,8 +38,7 @@ export const useLoopChartLoader = () => {
|
||||
type: number,
|
||||
slotNumber: number
|
||||
) => {
|
||||
const typeFolder =
|
||||
type === 3 ? "isolationswiderstand" : "schleifenwiderstand";
|
||||
const typeFolder = "schleifenwiderstand";
|
||||
|
||||
let url: string;
|
||||
|
||||
@@ -92,34 +99,67 @@ export const useLoopChartLoader = () => {
|
||||
};
|
||||
|
||||
//-----------------------------------------------------------------------------------LoopChartActionBar
|
||||
const LoopChartActionBar: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const LoopChartActionBar = forwardRef((_props, ref) => {
|
||||
const dispatch = useAppDispatch();
|
||||
// RSL Progress State – Dauer konfigurierbar über NEXT_PUBLIC_RSL_DURATION_SECONDS
|
||||
const TOTAL_DURATION = RSL_DURATION_SECONDS;
|
||||
const [rslRunning, setRslRunning] = useState(false);
|
||||
const [rslProgress, setRslProgress] = useState(0);
|
||||
|
||||
// Fortschritt aktualisieren
|
||||
useEffect(() => {
|
||||
if (!rslRunning) return;
|
||||
setRslProgress(0);
|
||||
const startedAt = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - startedAt) / 1000);
|
||||
if (elapsed >= TOTAL_DURATION) {
|
||||
setRslProgress(TOTAL_DURATION);
|
||||
setRslRunning(false);
|
||||
clearInterval(interval);
|
||||
// Optional automatische Daten-Nachladung anstoßen
|
||||
} else {
|
||||
setRslProgress(elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [rslRunning, TOTAL_DURATION]);
|
||||
|
||||
const startRslProgress = () => {
|
||||
setRslRunning(true);
|
||||
setRslProgress(0);
|
||||
};
|
||||
|
||||
const {
|
||||
vonDatum,
|
||||
bisDatum,
|
||||
selectedMode,
|
||||
selectedSlotType,
|
||||
|
||||
slotNumber,
|
||||
|
||||
isLoading,
|
||||
} = useSelector((state: RootState) => state.kabelueberwachungChartSlice);
|
||||
|
||||
const { chartTitle } = useSelector((state: RootState) => state.loopChartType);
|
||||
// Vom DateRangePicker-Slice (vom UI gewählte Werte)
|
||||
const { vonDatum: pickerVonDatum, bisDatum: pickerBisDatum } = useSelector(
|
||||
(state: RootState) => state.dateRangePicker
|
||||
);
|
||||
|
||||
const getApiUrl = (
|
||||
mode: "DIA0" | "DIA1" | "DIA2",
|
||||
type: number,
|
||||
slotNumber: number
|
||||
slotNumber: number,
|
||||
fromDate: string,
|
||||
toDate: string
|
||||
) => {
|
||||
const typeFolder =
|
||||
type === 3 ? "isolationswiderstand" : "schleifenwiderstand";
|
||||
const typeFolder = "schleifenwiderstand";
|
||||
|
||||
const baseUrl =
|
||||
process.env.NODE_ENV === "development"
|
||||
? `/api/cpl/slotDataAPIHandler?slot=${slotNumber}&messart=${typeFolder}&dia=${mode}&vonDatum=${vonDatum}&bisDatum=${bisDatum}`
|
||||
? `/api/cpl/slotDataAPIHandler?slot=${slotNumber}&messart=${typeFolder}&dia=${mode}&vonDatum=${fromDate}&bisDatum=${toDate}`
|
||||
: `${window.location.origin}/CPL?seite.ACP&${mode}=${formatDate(
|
||||
vonDatum
|
||||
)};${formatDate(bisDatum)};${slotNumber};${type};`;
|
||||
fromDate
|
||||
)};${formatDate(toDate)};${slotNumber};${type};`;
|
||||
console.log("baseUrl", baseUrl);
|
||||
|
||||
return baseUrl;
|
||||
@@ -130,15 +170,64 @@ const LoopChartActionBar: React.FC = () => {
|
||||
return `${year};${month};${day}`;
|
||||
};
|
||||
|
||||
const handleStartRSL = async () => {
|
||||
if (slotNumber === null) {
|
||||
alert("⚠️ Bitte zuerst einen KÜ auswählen!");
|
||||
return;
|
||||
}
|
||||
|
||||
const cgiUrl = `${window.location.origin}/CPL?kabelueberwachung.html&KS_${slotNumber}=1`;
|
||||
|
||||
try {
|
||||
console.log("🚀 Starte RSL Messung für Slot:", slotNumber);
|
||||
console.log("📡 CGI URL:", cgiUrl);
|
||||
|
||||
if (NODE_ENV === "development") {
|
||||
// DEV: externes Gerät mocken – sofort Erfolg simulieren
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
console.log("✅ [DEV] RSL Mock-Start ok für Slot", slotNumber);
|
||||
startRslProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(cgiUrl);
|
||||
if (!response.ok) throw new Error(`CGI-Fehler: ${response.status}`);
|
||||
console.log("✅ RSL Messung gestartet für Slot", slotNumber);
|
||||
startRslProgress();
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Starten der RSL Messung:", err);
|
||||
alert("❌ Fehler beim Starten der RSL Messung.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleFetchData = async () => {
|
||||
const type = selectedSlotType === "schleifenwiderstand" ? 4 : 3;
|
||||
|
||||
if (slotNumber === null) {
|
||||
alert("⚠️ Bitte zuerst einen Steckplatz auswählen!");
|
||||
alert("⚠️ Bitte zuerst einen KÜ auswählen!");
|
||||
return;
|
||||
}
|
||||
|
||||
const apiUrl = getApiUrl(selectedMode, type, slotNumber);
|
||||
// Meldungen laden, wenn Meldungen-Ansicht aktiv ist
|
||||
if (chartTitle === "Meldungen") {
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
const fromDate = pickerVonDatum ?? vonDatum;
|
||||
const toDate = pickerBisDatum ?? bisDatum;
|
||||
await dispatch(getMessagesThunk({ fromDate, toDate })).unwrap();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("❌ Fehler beim Laden der Meldungen:", message);
|
||||
alert("❌ Fehler beim Laden der Meldungen.");
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const fromDate = pickerVonDatum ?? vonDatum;
|
||||
const toDate = pickerBisDatum ?? bisDatum;
|
||||
const apiUrl = getApiUrl(selectedMode, type, slotNumber, fromDate, toDate);
|
||||
if (!apiUrl) return;
|
||||
|
||||
dispatch(setLoading(true));
|
||||
@@ -165,8 +254,8 @@ const LoopChartActionBar: React.FC = () => {
|
||||
console.log(" Slot:", slotNumber);
|
||||
console.log(" Typ:", selectedSlotType, "→", type);
|
||||
console.log(" Modus:", selectedMode);
|
||||
console.log(" Von:", vonDatum);
|
||||
console.log(" Bis:", bisDatum);
|
||||
console.log(" Von:", fromDate);
|
||||
console.log(" Bis:", toDate);
|
||||
console.log(" URL:", apiUrl);
|
||||
console.log(" Daten:", jsonData);
|
||||
|
||||
@@ -188,69 +277,130 @@ const LoopChartActionBar: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleFetchData,
|
||||
}));
|
||||
|
||||
// Sichtbarkeits-Flags
|
||||
const isMesskurve = chartTitle === "Messkurve";
|
||||
const isMeldungen = chartTitle === "Meldungen";
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center p-2 bg-gray-100 rounded-lg space-x-2">
|
||||
<div className="flex items-center">
|
||||
<label className="text-sm font-semibold">
|
||||
Steckplatz {slotNumber ?? "-"}
|
||||
</label>
|
||||
<div className="toolbar w-full flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center mr-2 min-w-[4rem]">
|
||||
<span className="text-xs font-semibold opacity-80 select-none">
|
||||
KÜ {slotNumber !== null ? slotNumber + 1 : "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-3 flex-1 justify-end">
|
||||
{/* DateRangePicker immer sichtbar */}
|
||||
<DateRangePicker />
|
||||
|
||||
<select
|
||||
value={selectedMode}
|
||||
onChange={(e) => {
|
||||
dispatch(
|
||||
setSelectedMode(e.target.value as "DIA0" | "DIA1" | "DIA2")
|
||||
);
|
||||
dispatch(setBrushRange({ startIndex: 0, endIndex: 0 }));
|
||||
}}
|
||||
className="px-3 py-1 bg-white border rounded text-sm"
|
||||
>
|
||||
<option value="DIA0">Alle Messwerte</option>
|
||||
<option value="DIA1">Stündliche Werte</option>
|
||||
<option value="DIA2">Tägliche Werte</option>
|
||||
</select>
|
||||
{/* Modus-Dropdown nur für Messkurve */}
|
||||
<div className={isMesskurve ? "" : "hidden"}>
|
||||
<Listbox
|
||||
value={selectedMode}
|
||||
onChange={(value) => {
|
||||
dispatch(setSelectedMode(value));
|
||||
dispatch(setBrushRange({ startIndex: 0, endIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
<div className="relative w-48">
|
||||
<Listbox.Button className="dropdown-surface w-full flex items-center justify-between">
|
||||
<span className="dropdown-text-fix">
|
||||
{
|
||||
{
|
||||
DIA0: "Alle Messwerte",
|
||||
DIA1: "Stündlich",
|
||||
DIA2: "Täglich",
|
||||
}[selectedMode]
|
||||
}
|
||||
</span>
|
||||
<i className="bi bi-chevron-down opacity-70" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="dropdown-options absolute z-50 mt-1 w-full max-h-60 overflow-auto">
|
||||
{["DIA0", "DIA1", "DIA2"].map((mode) => (
|
||||
<Listbox.Option
|
||||
key={mode}
|
||||
value={mode}
|
||||
className={({ selected, active }) =>
|
||||
`px-3 py-1.5 cursor-pointer rounded-sm m-0.5 ${
|
||||
selected
|
||||
? "dropdown-option-active"
|
||||
: active
|
||||
? "dropdown-option-hover"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{
|
||||
{
|
||||
DIA0: "Alle Messwerte",
|
||||
DIA1: "Stündlich",
|
||||
DIA2: "Täglich",
|
||||
}[mode]
|
||||
}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedSlotType}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as
|
||||
| "isolationswiderstand"
|
||||
| "schleifenwiderstand";
|
||||
dispatch(setSelectedSlotType(value));
|
||||
dispatch(
|
||||
setChartTitle(
|
||||
value === "isolationswiderstand"
|
||||
? "Isolationsmessung"
|
||||
: "Schleifenmessung"
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1 bg-white border rounded text-sm"
|
||||
>
|
||||
<option value="isolationswiderstand">Isolationswiderstand</option>
|
||||
<option value="schleifenwiderstand">Schleifenwiderstand</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
className="px-4 py-1 bg-littwin-blue text-white rounded text-sm"
|
||||
>
|
||||
Daten laden
|
||||
</button>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500">
|
||||
<div className="w-4 h-4 border-2 border-t-2 border-blue-500 rounded-full animate-spin" />
|
||||
<span>Lade Daten...</span>
|
||||
{/* Buttons */}
|
||||
{isMesskurve && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleStartRSL}
|
||||
className="btn-primary h-8 font-medium px-3"
|
||||
disabled={isLoading || rslRunning}
|
||||
type="button"
|
||||
>
|
||||
{rslRunning ? "RSL läuft…" : "RSL Messung starten"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
className="btn-primary h-8 font-medium px-3"
|
||||
disabled={rslRunning}
|
||||
type="button"
|
||||
>
|
||||
Daten laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isMeldungen && (
|
||||
<button
|
||||
onClick={handleFetchData}
|
||||
className="btn-primary h-8 font-medium px-4"
|
||||
disabled={rslRunning}
|
||||
type="button"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rslRunning && (
|
||||
<div className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="mb-4 text-center space-y-1">
|
||||
<p className="text-sm font-semibold">RSL Messung läuft</p>
|
||||
<p className="text-xs opacity-80">
|
||||
Bitte warten…{" "}
|
||||
{Math.min(100, Math.round((rslProgress / TOTAL_DURATION) * 100))}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-2/3 max-w-xl h-3 bg-[var(--color-border)] rounded overflow-hidden shadow-inner">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all ease-linear"
|
||||
style={{ width: `${(rslProgress / TOTAL_DURATION) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
LoopChartActionBar.displayName = "LoopChartActionBar";
|
||||
export default LoopChartActionBar;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client"; // LoopChartView.tsx
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import ReactModal from "react-modal";
|
||||
import LoopMeasurementChart from "./LoopMeasurementChart";
|
||||
import Report from "../IsoMeasurementChart/Report";
|
||||
import LoopChartActionBar from "./LoopChartActionBar";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { AppDispatch, RootState } from "@/redux/store";
|
||||
import {
|
||||
setChartOpen,
|
||||
setFullScreen,
|
||||
setSlotNumber,
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
setSelectedMode,
|
||||
setSelectedSlotType,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { setChartTitle as setLoopChartTitle } from "@/redux/slices/loopChartTypeSlice";
|
||||
import { resetBrushRange } from "@/redux/slices/brushSlice";
|
||||
import { resetDateRange } from "@/redux/slices/dateRangePickerSlice";
|
||||
|
||||
interface LoopChartViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
function LoopChartView({ isOpen, onClose, slotIndex }: LoopChartViewProps) {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const chartTitle = useSelector(
|
||||
(state: RootState) => state.loopChartType.chartTitle
|
||||
);
|
||||
|
||||
const isFullScreen = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice.isFullScreen
|
||||
);
|
||||
|
||||
// slotNumber nicht direkt benötigt – wird intern über Redux genutzt
|
||||
|
||||
// **Modal schließen + Redux-Status zurücksetzen**
|
||||
const handleClose = () => {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (date: Date) => date.toLocaleDateString("sv-SE");
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
dispatch(resetDateRange());
|
||||
dispatch(setSelectedMode("DIA0"));
|
||||
dispatch(setSelectedSlotType("schleifenwiderstand"));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setFullScreen(false));
|
||||
dispatch(resetBrushRange());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => dispatch(setFullScreen(!isFullScreen));
|
||||
|
||||
const actionBarRef = useRef<{ handleFetchData: () => void }>(null);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (date: Date) => date.toLocaleDateString("sv-SE");
|
||||
dispatch(setSlotNumber(slotIndex));
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
dispatch(setSelectedSlotType("schleifenwiderstand"));
|
||||
dispatch(setSelectedMode("DIA0"));
|
||||
const t = setTimeout(() => actionBarRef.current?.handleFetchData(), 120);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [isOpen, slotIndex, dispatch]);
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
ariaHideApp={false}
|
||||
style={{
|
||||
overlay: {
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
backdropFilter: "blur(2px)",
|
||||
},
|
||||
content: {
|
||||
inset: "50% auto auto 50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: isFullScreen ? "90vw" : "72rem",
|
||||
height: isFullScreen ? "90vh" : "38rem",
|
||||
padding: 0,
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
contentLabel="Schleifenwiderstand"
|
||||
>
|
||||
<header className="modal-header relative pr-56">
|
||||
<h3 className="text-sm font-semibold tracking-wide">
|
||||
Schleifenwiderstand
|
||||
</h3>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="icon-btn"
|
||||
aria-label={isFullScreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullScreen
|
||||
? "bi bi-fullscreen-exit"
|
||||
: "bi bi-arrows-fullscreen"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="icon-btn"
|
||||
aria-label="Schließen"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="bi bi-x-circle-fill"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-28">
|
||||
<Listbox
|
||||
value={chartTitle}
|
||||
onChange={(value: "Messkurve" | "Meldungen") =>
|
||||
dispatch(setLoopChartTitle(value))
|
||||
}
|
||||
>
|
||||
<div className="relative w-40">
|
||||
<Listbox.Button className="dropdown-surface w-full flex items-center justify-between h-8">
|
||||
<span className="dropdown-text-fix">{chartTitle}</span>
|
||||
<i className="bi bi-chevron-down text-sm opacity-70" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="dropdown-options absolute z-50 mt-1 w-full max-h-60 overflow-auto text-sm">
|
||||
{(["Messkurve", "Meldungen"] as const).map((option) => (
|
||||
<Listbox.Option
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ selected, active }) =>
|
||||
`px-3 py-1.5 cursor-pointer rounded-sm m-0.5 ${
|
||||
selected
|
||||
? "dropdown-option-active"
|
||||
: active
|
||||
? "dropdown-option-hover"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col flex-1 p-3 gap-3">
|
||||
<LoopChartActionBar ref={actionBarRef} />
|
||||
<div className="flex-1 relative">
|
||||
{chartTitle === "Messkurve" ? (
|
||||
<LoopMeasurementChart />
|
||||
) : (
|
||||
<Report moduleType="RSL" autoLoad={chartTitle === "Meldungen"} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ReactModal>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoopChartView;
|
||||
@@ -31,7 +31,7 @@ ChartJS.register(
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
import { getColor } from "../../../../../../utils/colors";
|
||||
import { getColor } from "@/utils/colors";
|
||||
import { PulseLoader } from "react-spinners";
|
||||
|
||||
type LoopMeasurementEntry = {
|
||||
@@ -118,24 +118,20 @@ const LoopMeasurementChart = () => {
|
||||
{
|
||||
label: "Messwert Minimum",
|
||||
data: loopMeasurementCurveChartData.map((e) => e.i).reverse(),
|
||||
borderColor: "lightgrey",
|
||||
backgroundColor: "rgba(211,211,211,0.5)",
|
||||
borderWidth: 2,
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
label: "Messwert Maximum",
|
||||
data: loopMeasurementCurveChartData.map((e) => e.a).reverse(),
|
||||
borderColor: "lightgrey",
|
||||
backgroundColor: "rgba(211,211,211,0.5)",
|
||||
borderWidth: 2,
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
order: 3,
|
||||
},
|
||||
selectedMode === "DIA0"
|
||||
? {
|
||||
@@ -147,7 +143,7 @@ const LoopMeasurementChart = () => {
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 3,
|
||||
order: 2,
|
||||
}
|
||||
: {
|
||||
label: "Messwert Durchschnitt",
|
||||
@@ -158,7 +154,7 @@ const LoopMeasurementChart = () => {
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 10,
|
||||
tension: 0.1,
|
||||
order: 3,
|
||||
order: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AppDispatch } from "../../../../../../redux/store";
|
||||
import { Chart, registerables } from "chart.js";
|
||||
import "chartjs-adapter-date-fns";
|
||||
import { getColor } from "../../../../../../utils/colors";
|
||||
import TDRChartActionBar from "./TDRChartActionBar";
|
||||
import { getReferenceCurveBySlotThunk } from "../../../../../../redux/thunks/getReferenceCurveBySlotThunk";
|
||||
|
||||
const TDRChart: React.FC<{ isFullScreen: boolean }> = ({ isFullScreen }) => {
|
||||
@@ -213,8 +212,6 @@ const TDRChart: React.FC<{ isFullScreen: boolean }> = ({ isFullScreen }) => {
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: isFullScreen ? "90%" : "28rem" }}>
|
||||
<TDRChartActionBar />
|
||||
|
||||
{tdrChartData.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-gray-500 italic">
|
||||
⚠️ Keine Daten verfügbar für diesen Slot
|
||||
|
||||
@@ -1,106 +1,149 @@
|
||||
"use client";
|
||||
// /components/main/kabelueberwachung/kue705FO/Charts/TDRChart/TDRChartActionBar.tsx
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import DateRangePicker from "@/components/common/DateRangePicker";
|
||||
import { useAppDispatch } from "@/redux/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import { getMessagesThunk } from "@/redux/thunks/getMessagesThunk";
|
||||
import { setLoading } from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { fetchTDMDataBySlotThunk } from "@/redux/thunks/getTDMListBySlotThunk";
|
||||
import { getTDRChartDataByIdThunk } from "@/redux/thunks/getTDRChartDataByIdThunk";
|
||||
import { getReferenceCurveBySlotThunk } from "@/redux/thunks/getReferenceCurveBySlotThunk"; // ⬅ import ergänzen
|
||||
import { getReferenceCurveBySlotThunk } from "@/redux/thunks/getReferenceCurveBySlotThunk";
|
||||
|
||||
const TDRChartActionBar: React.FC = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// ✅ Redux: selectedSlot aus kueChartMode (0-basiert)
|
||||
const { vonDatum, bisDatum, chartTitle } = useSelector(
|
||||
(s: RootState) => s.kabelueberwachungChartSlice
|
||||
);
|
||||
const { vonDatum: pickerVon, bisDatum: pickerBis } = useSelector(
|
||||
(s: RootState) => s.dateRangePicker
|
||||
);
|
||||
|
||||
const selectedSlot = useSelector(
|
||||
(state: RootState) => state.kueChartModeSlice.selectedSlot
|
||||
(s: RootState) => s.kueChartModeSlice.selectedSlot
|
||||
);
|
||||
|
||||
const tdmChartData = useSelector(
|
||||
(state: RootState) => state.tdmSingleChartSlice.data
|
||||
(s: RootState) => s.tdmSingleChartSlice.data
|
||||
);
|
||||
|
||||
const idsForSlot =
|
||||
selectedSlot !== null ? tdmChartData[selectedSlot] ?? [] : [];
|
||||
|
||||
const tdrDataById = useSelector(
|
||||
(state: RootState) => state.tdrDataByIdSlice.dataById
|
||||
(s: RootState) => s.tdrDataByIdSlice.dataById
|
||||
);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const currentChartData = selectedId !== null ? tdrDataById[selectedId] : [];
|
||||
|
||||
// 🔄 Dropdown-Auswahl: neue Messung laden
|
||||
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const id = parseInt(e.target.value);
|
||||
setSelectedId(id);
|
||||
dispatch(getTDRChartDataByIdThunk(id));
|
||||
const isMeldungen = chartTitle === "Meldungen";
|
||||
|
||||
// Progress for running TDR measurement
|
||||
const TDR_TOTAL_DURATION = parseInt(
|
||||
process.env.NEXT_PUBLIC_TDR_DURATION_SECONDS || "120",
|
||||
10
|
||||
);
|
||||
const [tdrRunning, setTdrRunning] = useState(false);
|
||||
const [tdrProgress, setTdrProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tdrRunning) return;
|
||||
setTdrProgress(0);
|
||||
const started = Date.now();
|
||||
const interval = setInterval(() => {
|
||||
const elapsed = Math.floor((Date.now() - started) / 1000);
|
||||
if (elapsed >= TDR_TOTAL_DURATION) {
|
||||
setTdrProgress(TDR_TOTAL_DURATION);
|
||||
setTdrRunning(false);
|
||||
clearInterval(interval);
|
||||
} else {
|
||||
setTdrProgress(elapsed);
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [tdrRunning, TDR_TOTAL_DURATION]);
|
||||
|
||||
const startTdrProgress = () => {
|
||||
setTdrRunning(true);
|
||||
setTdrProgress(0);
|
||||
};
|
||||
|
||||
const handleFetchMessages = async () => {
|
||||
const fromDate = pickerVon ?? vonDatum;
|
||||
const toDate = pickerBis ?? bisDatum;
|
||||
try {
|
||||
dispatch(setLoading(true));
|
||||
await dispatch(getMessagesThunk({ fromDate, toDate })).unwrap();
|
||||
} catch (e) {
|
||||
console.error("❌ Fehler beim Laden der Meldungen", e);
|
||||
alert("❌ Fehler beim Laden der Meldungen.");
|
||||
} finally {
|
||||
dispatch(setLoading(false));
|
||||
}
|
||||
};
|
||||
|
||||
// 📌 Referenz setzen (nutzt Slotnummer + 1 für die API)
|
||||
const handleSetReference = async () => {
|
||||
if (
|
||||
selectedSlot === null ||
|
||||
selectedId === null ||
|
||||
!currentChartData?.length
|
||||
!currentChartData.length
|
||||
)
|
||||
return;
|
||||
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV === "development";
|
||||
try {
|
||||
const slotNumber = selectedSlot + 1; // Slot ist 0-basiert, API will 1-basiert
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV === "development";
|
||||
|
||||
if (isDev) {
|
||||
await fetch("/api/cpl/updateTdrReferenceCurveAPIHandler", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
slot: slotNumber,
|
||||
data: currentChartData,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
const url = `/CPL?KTR${slotNumber}=${selectedId}`;
|
||||
await fetch(url, { method: "GET" });
|
||||
}
|
||||
if (!isDev) {
|
||||
const url = `/CPL?KTR${slotNumber}=${selectedId}`;
|
||||
const url = `/CPL?KTR${selectedSlot + 1}=${selectedId}`;
|
||||
const response = await fetch(url, { method: "GET" });
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Fehler beim Setzen der Referenz: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: lokale Speicherung und Redux-Update
|
||||
localStorage.setItem(
|
||||
`ref-curve-slot${selectedSlot}`,
|
||||
JSON.stringify(currentChartData)
|
||||
);
|
||||
|
||||
dispatch(getReferenceCurveBySlotThunk(selectedSlot));
|
||||
|
||||
alert("Referenzkurve wurde erfolgreich gesetzt!");
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Setzen der Referenzkurve:", error);
|
||||
} catch (err) {
|
||||
console.error("Fehler beim Setzen der Referenzkurve", err);
|
||||
alert("Fehler beim Setzen der Referenzkurve.");
|
||||
}
|
||||
};
|
||||
|
||||
// 📥 Beim Slot-Wechsel TDM-Liste + letzte ID laden
|
||||
const handleStartTDR = async () => {
|
||||
if (selectedSlot === null) {
|
||||
alert("⚠️ Bitte zuerst einen KÜ auswählen!");
|
||||
return;
|
||||
}
|
||||
const cgiUrl = `${window.location.origin}/CPL?/${window.location.pathname}&KTT${selectedSlot}=1`;
|
||||
try {
|
||||
const isDev = process.env.NEXT_PUBLIC_NODE_ENV === "development";
|
||||
if (isDev) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
startTdrProgress();
|
||||
return;
|
||||
}
|
||||
const response = await fetch(cgiUrl);
|
||||
if (!response.ok) throw new Error(`CGI-Fehler: ${response.status}`);
|
||||
startTdrProgress();
|
||||
} catch (err) {
|
||||
console.error("❌ Fehler beim Starten der TDR Messung", err);
|
||||
}
|
||||
};
|
||||
|
||||
// Load TDM list when slot changes
|
||||
useEffect(() => {
|
||||
if (selectedSlot !== null) {
|
||||
dispatch(fetchTDMDataBySlotThunk(selectedSlot)).then((action) => {
|
||||
// action can be a PayloadAction with payload or a rejected action
|
||||
const payload = (
|
||||
action as {
|
||||
payload?: { data?: { id: number; t: string; d: number }[] };
|
||||
}
|
||||
).payload;
|
||||
const slotData = payload?.data;
|
||||
if ((slotData ?? []).length > 0) {
|
||||
const lastId = (slotData ?? [])[0].id;
|
||||
const slotData = payload?.data ?? [];
|
||||
if (slotData.length > 0) {
|
||||
const lastId = slotData[0].id; // latest first
|
||||
setSelectedId(lastId);
|
||||
dispatch(getTDRChartDataByIdThunk(lastId));
|
||||
}
|
||||
@@ -109,53 +152,160 @@ const TDRChartActionBar: React.FC = () => {
|
||||
}, [selectedSlot, dispatch]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center p-2 bg-gray-100 rounded-lg space-x-4">
|
||||
{/* 🧩 Slot-Anzeige (1-basiert für Benutzer) */}
|
||||
<div className="text-sm font-semibold">
|
||||
{selectedSlot !== null
|
||||
? `Steckplatz ${selectedSlot + 1}`
|
||||
: "Kein Steckplatz gewählt"}
|
||||
<>
|
||||
<div className="toolbar w-full justify-between flex-wrap">
|
||||
{/* KÜ number left, controls right, like IsoChartActionBar */}
|
||||
<div className="flex items-center gap-2 pr-4">
|
||||
<span className="font-semibold uppercase tracking-wide text-muted">
|
||||
KÜ
|
||||
</span>
|
||||
<span className="font-medium px-2 py-0.5 rounded bg-surface-alt border border-base min-w-[3rem] text-center">
|
||||
{selectedSlot !== null ? selectedSlot + 1 : "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-1 justify-end">
|
||||
{isMeldungen ? (
|
||||
<>
|
||||
<DateRangePicker />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFetchMessages}
|
||||
className="btn-primary h-8 font-medium px-4"
|
||||
disabled={selectedSlot === null}
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{selectedId !== null && (
|
||||
<button
|
||||
onClick={handleSetReference}
|
||||
type="button"
|
||||
className="btn-primary h-8 px-3 font-medium"
|
||||
disabled={selectedSlot === null}
|
||||
>
|
||||
TDR-Kurve als Referenz speichern
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleStartTDR}
|
||||
type="button"
|
||||
disabled={selectedSlot === null || tdrRunning}
|
||||
className={`btn-primary h-8 px-4 whitespace-nowrap ${
|
||||
tdrRunning ? "opacity-90" : ""
|
||||
}`}
|
||||
>
|
||||
{tdrRunning
|
||||
? `TDR läuft... (${Math.min(
|
||||
100,
|
||||
Math.round((tdrProgress / TDR_TOTAL_DURATION) * 100)
|
||||
)}%)`
|
||||
: "TDR-Messung starten"}
|
||||
</button>
|
||||
<div className="ml-auto flex-1 min-w-[14rem] max-w-[30rem]">
|
||||
<Listbox
|
||||
value={selectedId}
|
||||
onChange={(id) => {
|
||||
setSelectedId(id);
|
||||
if (id !== null) dispatch(getTDRChartDataByIdThunk(id));
|
||||
}}
|
||||
disabled={idsForSlot.length === 0}
|
||||
>
|
||||
<div className="relative w-full">
|
||||
<Listbox.Button className="dropdown-surface w-full flex items-center justify-between h-8 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span className="dropdown-text-fix whitespace-nowrap overflow-hidden text-ellipsis pr-2">
|
||||
{selectedId
|
||||
? (() => {
|
||||
const selected = idsForSlot.find(
|
||||
(e) => e.id === selectedId
|
||||
);
|
||||
return selected
|
||||
? `${new Date(selected.t).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}
|
||||
)} – Fehlerstelle: ${selected.d} m`
|
||||
: "Wähle Messung";
|
||||
})()
|
||||
: "Wähle Messung"}
|
||||
</span>
|
||||
<i className="bi bi-chevron-down text-sm opacity-70" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="dropdown-options absolute z-50 mt-1 w-full max-h-72 overflow-auto text-sm bg-[var(--color-surface)] border border-base rounded-md shadow-lg p-1">
|
||||
{idsForSlot.map((entry) => {
|
||||
const dateLabel = new Date(entry.t).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}
|
||||
);
|
||||
const fullText = `${dateLabel} – Fehlerstelle: ${entry.d} m`;
|
||||
return (
|
||||
<Listbox.Option
|
||||
key={entry.id}
|
||||
value={entry.id}
|
||||
title={fullText}
|
||||
className={({ selected, active }) => {
|
||||
const base =
|
||||
"px-3 h-8 cursor-pointer rounded-sm m-0.5 flex items-center justify-start transition-colors text-[13px]";
|
||||
if (selected)
|
||||
return `${base} dropdown-option-active font-medium`;
|
||||
if (active)
|
||||
return `${base} dropdown-option-hover`;
|
||||
return `${base}`;
|
||||
}}
|
||||
>
|
||||
<span className="truncate w-full">{fullText}</span>
|
||||
</Listbox.Option>
|
||||
);
|
||||
})}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Progress Overlay */}
|
||||
{tdrRunning && (
|
||||
<div className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-[rgba(0,0,0,0.55)] backdrop-blur-sm">
|
||||
<div className="mb-4 text-center space-y-1">
|
||||
<p className="text-lg font-semibold text-white">
|
||||
TDR Messung läuft... kann bis zu zwei Minuten dauern
|
||||
</p>
|
||||
<p className="text-sm text-white/80">
|
||||
Bitte warten…{" "}
|
||||
{Math.min(
|
||||
100,
|
||||
Math.round((tdrProgress / TDR_TOTAL_DURATION) * 100)
|
||||
)}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-2/3 max-w-xl h-3 bg-white/20 rounded overflow-hidden shadow-inner">
|
||||
<div
|
||||
className="h-full bg-accent transition-all ease-linear"
|
||||
style={{
|
||||
width: `${(tdrProgress / TDR_TOTAL_DURATION) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ✅ Referenz setzen */}
|
||||
{selectedId !== null && (
|
||||
<button
|
||||
onClick={handleSetReference}
|
||||
className="border border-littwin-blue text-littwin-blue bg-white rounded px-3 py-1 text-sm hover:bg-blue-100"
|
||||
>
|
||||
TDR-Kurve als Referenz speichern
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 🔽 Dropdown für Messungen */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="tdrIdSelect" className="text-sm font-semibold">
|
||||
TDR Messung
|
||||
</label>
|
||||
<select
|
||||
id="tdrIdSelect"
|
||||
value={selectedId ?? ""}
|
||||
onChange={handleSelectChange}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
disabled={idsForSlot.length === 0}
|
||||
>
|
||||
<option value="">-- Wähle Messung --</option>
|
||||
{idsForSlot.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{new Date(entry.t).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}{" "}
|
||||
– Fehlerstelle: {entry.d} m
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"use client"; // TDRChartView.tsx
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import TDRChart from "./TDRChart";
|
||||
import TDRChartActionBar from "./TDRChartActionBar";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { AppDispatch, RootState } from "@/redux/store";
|
||||
import {
|
||||
setChartOpen,
|
||||
setFullScreen,
|
||||
setSlotNumber,
|
||||
setVonDatum,
|
||||
setBisDatum,
|
||||
setSelectedMode,
|
||||
setSelectedSlotType,
|
||||
setChartTitle,
|
||||
} from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { resetBrushRange } from "@/redux/slices/brushSlice";
|
||||
import {
|
||||
setSelectedSlot,
|
||||
setActiveMode,
|
||||
} from "@/redux/slices/kueChartModeSlice";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
import Report from "../IsoMeasurementChart/Report";
|
||||
|
||||
interface TDRChartViewProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
slotIndex: number;
|
||||
}
|
||||
|
||||
const TDRChartView: React.FC<TDRChartViewProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
slotIndex,
|
||||
}) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const { isFullScreen, chartTitle } = useSelector(
|
||||
(s: RootState) => s.kabelueberwachungChartSlice
|
||||
);
|
||||
|
||||
// Initialize defaults when opening
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (date: Date) => date.toLocaleDateString("sv-SE");
|
||||
|
||||
dispatch(setActiveMode("TDR"));
|
||||
dispatch(setSelectedSlot(slotIndex));
|
||||
dispatch(setSlotNumber(slotIndex));
|
||||
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
|
||||
if (chartTitle !== "Messkurve" && chartTitle !== "Meldungen") {
|
||||
dispatch(setChartTitle("Messkurve"));
|
||||
}
|
||||
// Only run when opened or slot changes or chartTitle invalid
|
||||
}, [isOpen, slotIndex, chartTitle, dispatch]);
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset generic chart slice to DIA1 isolationswiderstand defaults (same pattern as other modals)
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
const toISO = (date: Date) => date.toLocaleDateString("sv-SE");
|
||||
dispatch(setVonDatum(toISO(thirtyDaysAgo)));
|
||||
dispatch(setBisDatum(toISO(today)));
|
||||
dispatch(setSelectedMode("DIA1"));
|
||||
dispatch(setSelectedSlotType("isolationswiderstand"));
|
||||
dispatch(setChartOpen(false));
|
||||
dispatch(setFullScreen(false));
|
||||
dispatch(resetBrushRange());
|
||||
onClose();
|
||||
};
|
||||
|
||||
const toggleFullScreen = () => dispatch(setFullScreen(!isFullScreen));
|
||||
|
||||
return (
|
||||
<ReactModal
|
||||
isOpen={isOpen}
|
||||
onRequestClose={handleClose}
|
||||
ariaHideApp={false}
|
||||
style={{
|
||||
overlay: {
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
backdropFilter: "blur(2px)",
|
||||
},
|
||||
content: {
|
||||
inset: "50% auto auto 50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: isFullScreen ? "90vw" : "72rem",
|
||||
height: isFullScreen ? "90vh" : "38rem",
|
||||
padding: 0,
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "14px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
contentLabel="TDR Messung"
|
||||
>
|
||||
<header className="modal-header relative pr-56">
|
||||
<h3 className="text-sm font-semibold tracking-wide">TDR-Messung</h3>
|
||||
<div className="absolute top-2 right-2 flex gap-2">
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="icon-btn"
|
||||
aria-label={isFullScreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullScreen
|
||||
? "bi bi-fullscreen-exit"
|
||||
: "bi bi-arrows-fullscreen"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="icon-btn"
|
||||
aria-label="Schließen"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="bi bi-x-circle-fill"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="absolute top-2 right-28">
|
||||
<Listbox
|
||||
value={chartTitle}
|
||||
onChange={(value: "Messkurve" | "Meldungen") =>
|
||||
dispatch(setChartTitle(value))
|
||||
}
|
||||
>
|
||||
<div className="relative w-40">
|
||||
<Listbox.Button className="dropdown-surface w-full flex items-center justify-between h-8">
|
||||
<span className="dropdown-text-fix">{chartTitle}</span>
|
||||
<i className="bi bi-chevron-down text-sm opacity-70" />
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="dropdown-options absolute z-50 mt-1 w-full max-h-60 overflow-auto text-sm">
|
||||
{(["Messkurve", "Meldungen"] as const).map((option) => (
|
||||
<Listbox.Option
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ selected, active }) =>
|
||||
`px-3 py-1.5 cursor-pointer rounded-sm m-0.5 ${
|
||||
selected
|
||||
? "dropdown-option-active"
|
||||
: active
|
||||
? "dropdown-option-hover"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col flex-1 p-3 gap-3">
|
||||
{/* Action Bar (wie bei ISO / Loop) */}
|
||||
<TDRChartActionBar />
|
||||
<div className="flex-1 relative">
|
||||
{chartTitle === "Messkurve" ? (
|
||||
<TDRChart isFullScreen={isFullScreen} />
|
||||
) : (
|
||||
<Report moduleType="TDR" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ReactModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default TDRChartView;
|
||||
@@ -1,10 +1,26 @@
|
||||
"use client"; // components/modules/kue705FO/Kue705FO.tsx
|
||||
import React, { useState, useRef, useMemo } from "react";
|
||||
import React, { useState, useMemo, useEffect, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Marquee: any = dynamic(() => import("react-fast-marquee"), {
|
||||
ssr: false,
|
||||
});
|
||||
import { useSelector } from "react-redux";
|
||||
import KueModal from "./modals/SettingsModalWrapper";
|
||||
// import FallSensors from "../../fall-detection-sensors/FallSensors";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css"; // Import Bootstrap Icons
|
||||
import { Kue705FOProps } from "../../../../types/Kue705FOProps";
|
||||
import ChartSwitcher from "./Charts/ChartSwitcher";
|
||||
// Import the new specialized ChartView components
|
||||
import IsoChartView from "./Charts/IsoMeasurementChart/IsoChartView";
|
||||
import LoopChartView from "./Charts/LoopMeasurementChart/LoopChartView";
|
||||
import TDRChartView from "./Charts/TDRChart/TDRChartView";
|
||||
import KVZChartView from "./Charts/KVZChart/KVZChartView";
|
||||
import SlotActivityOverlay from "./SlotActivityOverlay";
|
||||
// Keep ChartSwitcher import for backwards compatibility if needed
|
||||
// import ChartSwitcher from "./Charts/ChartSwitcher";
|
||||
// Remove separate chart imports since we use ChartView components
|
||||
// import IsoMeasurementChart from "./Charts/IsoMeasurementChart/IsoMeasurementChart";
|
||||
// import LoopMeasurementChart from "./Charts/LoopMeasurementChart/LoopMeasurementChart";
|
||||
//-------Redux Toolkit--------
|
||||
import { RootState } from "../../../../redux/store";
|
||||
import { useDispatch } from "react-redux";
|
||||
@@ -16,15 +32,14 @@ import useIsoDisplay from "./hooks/useIsoDisplay";
|
||||
import useLoopDisplay from "./hooks/useLoopDisplay";
|
||||
import useModulName from "./hooks/useModulName";
|
||||
|
||||
import type { Chart } from "chart.js";
|
||||
|
||||
//--------handlers----------------
|
||||
import handleButtonClick from "./kue705FO-Funktionen/handleButtonClick";
|
||||
// Keep needed imports
|
||||
import handleOpenModal from "./handlers/handleOpenModal";
|
||||
import handleCloseModal from "./handlers/handleCloseModal";
|
||||
import handleOpenChartModal from "./handlers/handleOpenChartModal";
|
||||
import handleCloseChartModal from "./handlers/handleCloseChartModal";
|
||||
import handleRefreshClick from "./handlers/handleRefreshClick";
|
||||
// Remove unused chart modal handlers since we use direct ChartView components
|
||||
// import handleOpenChartModal from "./handlers/handleOpenChartModal";
|
||||
// import handleCloseChartModal from "./handlers/handleCloseChartModal";
|
||||
// import handleRefreshClick from "./handlers/handleRefreshClick";
|
||||
|
||||
const Kue705FO: React.FC<Kue705FOProps> = ({
|
||||
isolationswert,
|
||||
@@ -32,7 +47,6 @@ const Kue705FO: React.FC<Kue705FOProps> = ({
|
||||
modulName,
|
||||
kueOnline,
|
||||
slotIndex,
|
||||
tdrLocation,
|
||||
}) => {
|
||||
/* console.log(
|
||||
`Rendering Kue705FO - SlotIndex: ${slotIndex}, ModulName: ${modulName}`
|
||||
@@ -41,35 +55,57 @@ const Kue705FO: React.FC<Kue705FOProps> = ({
|
||||
const dispatch = useDispatch();
|
||||
const { kueName } = useSelector((state: RootState) => state.kueDataSlice);
|
||||
|
||||
const [activeButton, setActiveButton] = useState<"Schleife" | "TDR">(
|
||||
// Admin authentication hook for security - using showModal as true for continuous auth check
|
||||
// Admin Auth hook retained (result not currently needed after KVZ visibility change)
|
||||
// const { isAdminLoggedIn } = useAdminAuth(true);
|
||||
|
||||
// Modulname (max 48 Zeichen) vorbereiten
|
||||
const moduleNameRaw = useMemo(
|
||||
() => kueName?.[slotIndex] || `Modul ${slotIndex + 1}`,
|
||||
[kueName, slotIndex]
|
||||
);
|
||||
const moduleName48 = useMemo(
|
||||
() =>
|
||||
typeof moduleNameRaw === "string"
|
||||
? moduleNameRaw.slice(0, 48)
|
||||
: String(moduleNameRaw),
|
||||
[moduleNameRaw]
|
||||
);
|
||||
|
||||
const [activeButton, setActiveButton] = useState<"Schleife" | "TDR" | "ISO">(
|
||||
"Schleife"
|
||||
);
|
||||
|
||||
const [loopTitleText, setloopTitleText] = useState(
|
||||
"Schleifenwiderstand [kOhm]"
|
||||
);
|
||||
const [, setloopTitleText] = useState("Schleifenwiderstand [kOhm]");
|
||||
const [isoDisplayText] = useState("Aderbruch");
|
||||
const [groundFaultDisplayText] = useState("Erdschluss");
|
||||
const [loopFaultDisplayText] = useState("Schleifenfehler");
|
||||
const [isoFaultDisplayText] = useState("Isolationsfehler");
|
||||
const [isoGreaterThan200] = useState(">200 MOhm");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showChartModal, setShowChartModal] = useState(false);
|
||||
// Separate modal states for each ChartView
|
||||
const [showIsoModal, setShowIsoModal] = useState(false);
|
||||
const [showRslModal, setShowRslModal] = useState(false);
|
||||
const [showTdrModal, setShowTdrModal] = useState(false);
|
||||
const [showKvzModal, setShowKvzModal] = useState(false);
|
||||
// Keep original showChartModal for backwards compatibility if needed
|
||||
// const [showChartModal, setShowChartModal] = useState(false);
|
||||
// Removed unused loopMeasurementCurveChartData state
|
||||
|
||||
//------- Redux-Variablen abrufen--------------------------------
|
||||
const {
|
||||
kueVersion: reduxKueVersion,
|
||||
tdrActive,
|
||||
kueCableBreak: kueCableBreakRaw,
|
||||
kueGroundFault: kueGroundFaultRaw,
|
||||
kueAlarm1: kueAlarm1Raw,
|
||||
kueAlarm2: kueAlarm2Raw,
|
||||
kueOverflow: kueOverflowRaw,
|
||||
kuePSTmMinus96V, // <- richtig, weil so im State vorhanden
|
||||
tdrActive, // <- TDR aktiv Status hinzugefügt
|
||||
kvzPresence, // <- KVz Presence Array hinzugefügt
|
||||
kvzActive, // <- KVz Active Array hinzugefügt
|
||||
// kvzStatus, // <- KVz LED Status Array (jetzt nur im KVZ Modal verwendet)
|
||||
} = useSelector((state: RootState) => state.kueDataSlice);
|
||||
|
||||
//---------------------------------------------
|
||||
@@ -96,15 +132,59 @@ const Kue705FO: React.FC<Kue705FOProps> = ({
|
||||
//-------------------------handlers-------------------------
|
||||
const openModal = () => handleOpenModal(setShowModal);
|
||||
const closeModal = () => handleCloseModal(setShowModal);
|
||||
const openChartModal = () =>
|
||||
handleOpenChartModal(setShowChartModal, dispatch, slotIndex, activeButton);
|
||||
const refreshClick = () =>
|
||||
handleRefreshClick(activeButton, slotIndex, setLoading);
|
||||
// Create a ref for the chart instance to pass as the second argument
|
||||
const chartInstance = useRef<Chart | null>(null);
|
||||
|
||||
const closeChartModal = () =>
|
||||
handleCloseChartModal(setShowChartModal, chartInstance);
|
||||
// New ChartView handlers - direct modal opening
|
||||
const openIsoModal = () => {
|
||||
setActiveButton("ISO");
|
||||
// Set Redux state for ISO type
|
||||
dispatch({
|
||||
type: "kabelueberwachungChart/setSelectedSlotType",
|
||||
payload: 1, // 1 = Isolationswiderstand
|
||||
});
|
||||
dispatch({
|
||||
type: "kabelueberwachungChart/setSlotNumber",
|
||||
payload: slotIndex,
|
||||
});
|
||||
setShowIsoModal(true);
|
||||
};
|
||||
|
||||
const closeIsoModal = () => {
|
||||
setShowIsoModal(false);
|
||||
};
|
||||
|
||||
const openRslModal = () => {
|
||||
setActiveButton("Schleife");
|
||||
setloopTitleText("Schleifenwiderstand [kOhm]");
|
||||
setLoopDisplayValue(Number(schleifenwiderstand));
|
||||
dispatch({
|
||||
type: "kabelueberwachungChart/setSelectedSlotType",
|
||||
payload: 2,
|
||||
}); // RSL type
|
||||
dispatch({
|
||||
type: "kabelueberwachungChart/setSlotNumber",
|
||||
payload: slotIndex,
|
||||
});
|
||||
setShowRslModal(true);
|
||||
};
|
||||
|
||||
const closeRslModal = () => {
|
||||
setShowRslModal(false);
|
||||
};
|
||||
|
||||
const openTdrModal = () => {
|
||||
setActiveButton("TDR");
|
||||
setloopTitleText("Entfernung [km]");
|
||||
setShowTdrModal(true);
|
||||
};
|
||||
|
||||
const closeTdrModal = () => {
|
||||
setShowTdrModal(false);
|
||||
};
|
||||
|
||||
const openKvzModal = () => {
|
||||
setShowKvzModal(true);
|
||||
};
|
||||
const closeKvzModal = () => setShowKvzModal(false);
|
||||
//----------------------------------
|
||||
//hooks einbinden
|
||||
const kueVersion = useKueVersion(slotIndex, reduxKueVersion);
|
||||
@@ -132,45 +212,87 @@ const Kue705FO: React.FC<Kue705FOProps> = ({
|
||||
);
|
||||
const { setCurrentModulName } = useModulName(slotIndex, modulName);
|
||||
//---------------------------------
|
||||
//---------------------------------
|
||||
const tdmChartData = useSelector(
|
||||
(state: RootState) => state.tdmChartSlice.data
|
||||
// Version-gate für Laufschrift: erst ab V4.30 aktiv
|
||||
const parseVersion = (v?: string): [number, number, number] => {
|
||||
if (!v) return [0, 0, 0];
|
||||
const m = String(v).match(/(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
||||
if (!m) return [0, 0, 0];
|
||||
const major = parseInt(m[1] || "0", 10) || 0;
|
||||
const minor = parseInt(m[2] || "0", 10) || 0;
|
||||
const patch = parseInt(m[3] || "0", 10) || 0;
|
||||
return [major, minor, patch];
|
||||
};
|
||||
const gte = (a: [number, number, number], b: [number, number, number]) => {
|
||||
if (a[0] !== b[0]) return a[0] > b[0];
|
||||
if (a[1] !== b[1]) return a[1] > b[1];
|
||||
return a[2] >= b[2];
|
||||
};
|
||||
const marqueeOverride =
|
||||
process.env.NEXT_PUBLIC_ENABLE_KUE_MARQUEE === "1" ||
|
||||
process.env.NEXT_PUBLIC_ENABLE_KUE_MARQUEE === "true";
|
||||
const scrollFeatureEnabled = useMemo(
|
||||
() => marqueeOverride || gte(parseVersion(kueVersion), [4, 30, 0]),
|
||||
[kueVersion, marqueeOverride]
|
||||
);
|
||||
const latestTdrDistanceMeters =
|
||||
Array.isArray(tdmChartData?.[slotIndex]) &&
|
||||
tdmChartData[slotIndex].length > 0 &&
|
||||
typeof tdmChartData[slotIndex][0].d === "number"
|
||||
? tdmChartData[slotIndex][0].d
|
||||
: 0;
|
||||
|
||||
const latestTdrDistance = Number((latestTdrDistanceMeters / 1000).toFixed(3));
|
||||
//setLoopDisplayValue(latestTdrDistance);
|
||||
// Überlängen-Erkennung für Laufschrift
|
||||
const nameContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [shouldScroll, setShouldScroll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const measure = () => {
|
||||
if (!scrollFeatureEnabled) {
|
||||
setShouldScroll(false);
|
||||
return;
|
||||
}
|
||||
const container = nameContainerRef.current;
|
||||
const text = measureTextRef.current;
|
||||
if (!container || !text) {
|
||||
setShouldScroll(false);
|
||||
return;
|
||||
}
|
||||
const needs = text.scrollWidth > container.clientWidth + 2;
|
||||
setShouldScroll(needs);
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [moduleName48, scrollFeatureEnabled]);
|
||||
//---------------------------------
|
||||
// TDR Distanz wird im Display nicht angezeigt – Daten für Modal werden separat geladen
|
||||
|
||||
//---------------------------------
|
||||
|
||||
const loopValue =
|
||||
activeButton === "TDR"
|
||||
? latestTdrDistance
|
||||
: typeof schleifenwiderstand === "number"
|
||||
const rslValue =
|
||||
typeof schleifenwiderstand === "number"
|
||||
? schleifenwiderstand
|
||||
: Number(schleifenwiderstand);
|
||||
|
||||
const { loopDisplayValue, setLoopDisplayValue } = useLoopDisplay(
|
||||
loopValue,
|
||||
rslValue,
|
||||
activeButton
|
||||
);
|
||||
|
||||
// TDR aktiv Status für diesen Slot prüfen
|
||||
const isTdrActiveForSlot = tdrActive?.[slotIndex] === 1;
|
||||
|
||||
// KVz aktiv Status für diesen Slot prüfen - nur wenn Admin authentifiziert ist, KVz vorhanden ist UND aktiviert ist
|
||||
// Anpassung: KVZ Button soll sichtbar/benutzbar bleiben, auch wenn Admin sich abmeldet,
|
||||
// sobald KVZ Präsenz + Aktiv-Flag gesetzt sind. Admin wird nur zum Aktivieren benötigt.
|
||||
const isKvzActiveForSlot =
|
||||
kvzPresence?.[slotIndex] === 1 && kvzActive?.[slotIndex] === 1;
|
||||
|
||||
// Removed useChartData(loopMeasurementCurveChartData) as the state was unused
|
||||
|
||||
//---------------------------------
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-gray-300 w-[7.25rem] h-[24.375rem] border border-gray-400 transform laptop:-translate-y-12 2xl:-translate-y-0
|
||||
scale-100 sm:scale-95 md:scale-100 lg:scale-105 xl:scale-90 2xl:scale-125 top-3 qhd:scale-150 qhd:-translate-y-0
|
||||
|
||||
"
|
||||
className="relative bg-gray-300 w-[7.25rem] h-[23.375rem] border border-gray-400 transform laptop:-translate-y-12 2xl:-translate-y-0
|
||||
scale-100 sm:scale-95 md:scale-100 lg:scale-105 xl:scale-90 2xl:scale-125 top-3 qhd:scale-150 qhd:-translate-y-0"
|
||||
>
|
||||
{/* Per-slot activity overlay */}
|
||||
<SlotActivityOverlay slotIndex={slotIndex} />
|
||||
{kueOnline === 1 ? (
|
||||
<>
|
||||
<div className="relative w-[7.075rem] h-[15.156rem] bg-littwin-blue border-[0.094rem] border-gray-400 z-0">
|
||||
@@ -220,144 +342,227 @@ const Kue705FO: React.FC<Kue705FOProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anzeige des Isolation */}
|
||||
<div className="relative mt-[3.125rem] mx-auto bg-black text-white w-[6.25rem] h-[2.5rem] flex items-center justify-center text-[1.125rem] z-10">
|
||||
<div className="text-center">
|
||||
{/* Schwarzes Display mit drei Zeilen: Alarm, ISO, Schleife */}
|
||||
<div className="relative mt-[3.125rem] mx-auto bg-black text-white w-[6.8rem] h-[3.1rem] flex flex-col items-center justify-between z-10 p-1">
|
||||
<div className="text-center w-full flex flex-col justify-between items-center h-full">
|
||||
{/* 1. Zeile: Alarmtext in Rot, sonst "Status: OK" */}
|
||||
<span
|
||||
className={
|
||||
className={`whitespace-nowrap block text-[0.65rem] font-semibold ${
|
||||
Number(kuePSTmMinus96V?.[slotIndex]) === 1 ||
|
||||
Number(kueCableBreak?.[slotIndex]) === 1 ||
|
||||
Number(kueGroundFault?.[slotIndex]) === 1 ||
|
||||
Number(kueAlarm1?.[slotIndex]) === 1 ||
|
||||
Number(kueAlarm2?.[slotIndex]) === 1
|
||||
? "text-red-500 text-[0.875rem]"
|
||||
: Number(kueOverflow?.[slotIndex]) === 1
|
||||
? "text-white text-[0.875rem]"
|
||||
: ""
|
||||
}
|
||||
? "text-red-500"
|
||||
: "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{isoDisplayValue}
|
||||
{Number(kuePSTmMinus96V?.[slotIndex]) === 1
|
||||
? "Messpannung"
|
||||
: Number(kueCableBreak?.[slotIndex]) === 1
|
||||
? "Aderbruch"
|
||||
: Number(kueGroundFault?.[slotIndex]) === 1
|
||||
? "Erdschluss"
|
||||
: Number(kueAlarm1?.[slotIndex]) === 1
|
||||
? "Isolationsfehler"
|
||||
: Number(kueAlarm2?.[slotIndex]) === 1
|
||||
? "Schleifenfehler"
|
||||
: " "}
|
||||
{"\u00A0"}
|
||||
{/* Status: OK*/}
|
||||
</span>
|
||||
{/* 2. Zeile: ISO-Wert, immer anzeigen */}
|
||||
<span
|
||||
className={`whitespace-nowrap block text-[0.65rem] font-semibold ${
|
||||
Number(kueAlarm1?.[slotIndex]) === 1 ? "text-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
{isoDisplayValue === "Abgleich"
|
||||
? "ISO: Abgleich"
|
||||
: `ISO: ${Number(isolationswert)
|
||||
.toFixed(2)
|
||||
.replace(".", ",")} MOhm`}
|
||||
</span>
|
||||
{/* 3. Zeile: Schleifenwert (RSL) immer anzeigen, unabhängig von aktivem Button */}
|
||||
<span
|
||||
className={`whitespace-nowrap block text-[0.65rem] font-semibold ${
|
||||
Number(kueAlarm2?.[slotIndex]) === 1 ? "text-red-500" : ""
|
||||
}`}
|
||||
>
|
||||
{`RSL: ${Number(loopDisplayValue)
|
||||
.toFixed(3)
|
||||
.replace(".", ",")} kOhm`}
|
||||
</span>
|
||||
|
||||
{Number(kuePSTmMinus96V?.[slotIndex]) !== 1 &&
|
||||
Number(kueCableBreak?.[slotIndex]) !== 1 &&
|
||||
Number(kueGroundFault?.[slotIndex]) !== 1 &&
|
||||
Number(kueAlarm1?.[slotIndex]) !== 1 &&
|
||||
Number(kueAlarm2?.[slotIndex]) !== 1 &&
|
||||
Number(kueOverflow?.[slotIndex]) !== 1 && (
|
||||
<div className="text-[0.5rem]">ISO MOhm</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-0 left-[4.688rem] w-[0.188rem] h-full bg-white z-0"></div>
|
||||
<div className="absolute top-[2.5rem] left-[4.688rem] w-[2.5rem] h-[0.188rem] bg-white z-0"></div>
|
||||
|
||||
<div className="absolute bottom-[1.25rem] left-0 right-0 text-black text-[0.625rem] bg-gray-300 p-[0.063rem] text-center">
|
||||
{kueName?.[slotIndex] || `Modul ${slotIndex + 1}`}
|
||||
{/* Hidden measuring span for overflow detection (kept measurable) */}
|
||||
<span
|
||||
ref={measureTextRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -9999,
|
||||
top: -9999,
|
||||
visibility: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{moduleName48}
|
||||
</span>
|
||||
|
||||
<div
|
||||
ref={nameContainerRef}
|
||||
className="absolute bottom-[1.25rem] left-0 right-0 text-black text-[0.625rem] bg-gray-300 p-[0.063rem] overflow-hidden"
|
||||
>
|
||||
{shouldScroll && scrollFeatureEnabled ? (
|
||||
<Marquee pauseOnHover gradient={false} speed={40}>
|
||||
<span className="pr-8 whitespace-nowrap" title={moduleName48}>
|
||||
{moduleName48}
|
||||
</span>
|
||||
</Marquee>
|
||||
) : (
|
||||
<span
|
||||
className="block text-center whitespace-nowrap"
|
||||
title={moduleName48}
|
||||
>
|
||||
{moduleName48}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-[0.063rem] right-[0.063rem] text-black text-[0.5rem]">
|
||||
{kueVersion}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex mt-3 space-x-[0.063rem] ">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleButtonClick(
|
||||
"Schleife",
|
||||
setActiveButton,
|
||||
setloopTitleText,
|
||||
(value) =>
|
||||
setLoopDisplayValue(
|
||||
typeof value === "number" ? value : Number(value)
|
||||
), // Hier sicherstellen, dass nur number übergeben wird
|
||||
Number(schleifenwiderstand), // <- Stelle sicher, dass es eine Zahl ist
|
||||
tdrLocation,
|
||||
dispatch,
|
||||
slotIndex
|
||||
)
|
||||
}
|
||||
className={`w-[50%] h-[1.563rem] text-white text-[0.625rem] flex items-center justify-center ${
|
||||
activeButton === "Schleife" ? "bg-littwin-blue" : "bg-gray-400"
|
||||
}`}
|
||||
>
|
||||
Schleife
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveButton("TDR");
|
||||
setloopTitleText("Entfernung [Km]");
|
||||
|
||||
const latestTdrDistanceMeters =
|
||||
Array.isArray(tdmChartData?.[slotIndex]) &&
|
||||
tdmChartData[slotIndex].length > 0 &&
|
||||
typeof tdmChartData[slotIndex][0].d === "number"
|
||||
? tdmChartData[slotIndex][0].d
|
||||
: 0;
|
||||
|
||||
const latestTdrDistance = Number(
|
||||
(latestTdrDistanceMeters / 1000).toFixed(3)
|
||||
);
|
||||
setLoopDisplayValue(latestTdrDistance);
|
||||
}}
|
||||
className={`w-[50%] h-[1.563rem] text-white text-[0.625rem] flex items-center justify-center ${
|
||||
Array.isArray(tdrActive) && tdrActive[slotIndex] === 0
|
||||
? "bg-gray-200 cursor-not-allowed" // Deaktiviert: Hellgrau
|
||||
: activeButton === "TDR"
|
||||
? "bg-littwin-blue" // Aktiviert: Littwin Blau
|
||||
: "bg-gray-400" // Nicht geklickt: Dunkelgrau
|
||||
}`}
|
||||
disabled={Array.isArray(tdrActive) && tdrActive[slotIndex] === 0} // Button deaktiviert, wenn TDR für diesen Slot nicht aktiv ist
|
||||
>
|
||||
TDR
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* loopDisplay: Zeigt Schleifenwiderstand oder TDR-Distanz an, je nach Modus */}
|
||||
<div className="absolute bottom-[0.063rem] left-[0.068rem] w-[7.074rem] h-[6.1rem] bg-gray-300 border-[0.094rem] border-gray-400 p-[0.063rem]">
|
||||
<span className="text-black text-[0.438rem] absolute top-[0.125rem] left-[0.063rem] mt-1">
|
||||
{loopTitleText}
|
||||
{/* Modal für Einstellungen */}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
{/* Das soll rausgenommen werden
|
||||
<p>Kein Modul im Slot {slotIndex + 0}</p>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
{/* Messkurven-Button unter dem Modul */}
|
||||
{kueOnline === 1 && (
|
||||
<>
|
||||
{/*
|
||||
|
||||
Überschrift: Detailansicht
|
||||
ISO und RSL als Buttons (Firmenblau) nebeneinander
|
||||
TDR und KVz Buttons (Firmenblau) nebeneinander
|
||||
Wenn kein TDR oder kein KVz: nur grauer Button ohne Text
|
||||
|
||||
|
||||
*/}
|
||||
<div className="flex flex-col items-center w-full px-2 mt-2 space-y-2">
|
||||
{/* Detailansicht Header */}
|
||||
<span className="text-black text-[0.625rem] font-semibold">
|
||||
Detailansicht
|
||||
</span>
|
||||
|
||||
<div className="relative w-full h-[2.813rem] bg-gray-100 border border-gray-400 flex items-center justify-center mt-4">
|
||||
{/* ISO and RSL Buttons */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={refreshClick} // Dynamische Funktion basierend auf aktivem Button
|
||||
className="absolute -top-[0.063rem] -right-[0.063rem] w-[1.25rem] h-[1.25rem] bg-gray-400 flex items-center justify-center"
|
||||
disabled={loading} // Disable button while loading
|
||||
onClick={openIsoModal}
|
||||
className="bg-littwin-blue text-white text-[0.625rem] flex items-center justify-center p-2 min-w-[2.5rem]"
|
||||
>
|
||||
<span className="text-white text-[1.125rem]">⟳</span>
|
||||
ISO
|
||||
</button>
|
||||
<button
|
||||
onClick={openRslModal}
|
||||
className="bg-littwin-blue text-white text-[0.625rem] flex items-center justify-center p-2 min-w-[2.5rem]"
|
||||
>
|
||||
RSL
|
||||
</button>
|
||||
|
||||
<div className="absolute bottom-[0.313rem] left-1/2 transform -translate-x-1/2 w-[6.25rem] flex justify-center items-center">
|
||||
<div className="text-center text-black text-[0.625rem]">
|
||||
<p>
|
||||
{loopDisplayValue +
|
||||
(activeButton === "Schleife" ? " KOhm" : " Km")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={openChartModal} // Öffnet das Chart-Modal
|
||||
className="w-full h-[1.563rem] bg-littwin-blue text-white text-[0.625rem] flex items-center justify-center mt-[0.063rem]"
|
||||
>
|
||||
Messkurve
|
||||
</button>
|
||||
{/* TDR and KVz Buttons */}
|
||||
<div className="flex space-x-2 p-1">
|
||||
{/* TDR Button - blau mit Text wenn aktiv, grau ohne Text wenn inaktiv */}
|
||||
<button
|
||||
onClick={isTdrActiveForSlot ? openTdrModal : undefined}
|
||||
className={`${
|
||||
isTdrActiveForSlot
|
||||
? "bg-littwin-blue text-white cursor-pointer"
|
||||
: "bg-gray-400 cursor-default"
|
||||
} text-[0.625rem] flex items-center justify-center p-2 min-w-[2.5rem]`}
|
||||
>
|
||||
{isTdrActiveForSlot ? "TDR" : "\u00A0\u00A0\u00A0"}
|
||||
</button>
|
||||
|
||||
{/* KVz Button - blau mit Text wenn aktiv, grau ohne Text wenn inaktiv */}
|
||||
<button
|
||||
onClick={isKvzActiveForSlot ? openKvzModal : undefined}
|
||||
className={`${
|
||||
isKvzActiveForSlot
|
||||
? "bg-littwin-blue text-white cursor-pointer"
|
||||
: "bg-gray-400 cursor-default"
|
||||
} text-[0.625rem] flex items-center justify-center p-2 min-w-[2.5rem]`}
|
||||
disabled={!isKvzActiveForSlot}
|
||||
title={
|
||||
isKvzActiveForSlot ? "KVZ öffnen" : "KVZ nicht verfügbar"
|
||||
}
|
||||
>
|
||||
{isKvzActiveForSlot ? "KVZ" : "\u00A0\u00A0\u00A0"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Messkurve Button */}
|
||||
|
||||
{/* TDR Messkurve und Schleife Messkurve Buttons */}
|
||||
<div className="flex flex-col space-y-2 w-full"></div>
|
||||
</div>
|
||||
|
||||
{/* Modal für Messkurve */}
|
||||
{showChartModal && (
|
||||
<ChartSwitcher
|
||||
isOpen={showChartModal}
|
||||
onClose={closeChartModal}
|
||||
{/* ISO Chart Modal */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[0.125rem] bg-gray-400"></div>
|
||||
|
||||
{/* ISO Chart Modal */}
|
||||
<IsoChartView
|
||||
isOpen={showIsoModal}
|
||||
onClose={closeIsoModal}
|
||||
slotIndex={slotIndex}
|
||||
/>
|
||||
|
||||
{/* RSL Chart Modal */}
|
||||
<LoopChartView
|
||||
isOpen={showRslModal}
|
||||
onClose={closeRslModal}
|
||||
slotIndex={slotIndex}
|
||||
/>
|
||||
|
||||
{/* TDR Chart Modal - nur wenn TDR aktiv ist */}
|
||||
{isTdrActiveForSlot && (
|
||||
<TDRChartView
|
||||
isOpen={showTdrModal}
|
||||
onClose={closeTdrModal}
|
||||
slotIndex={slotIndex}
|
||||
/>
|
||||
)}
|
||||
{isKvzActiveForSlot && (
|
||||
<KVZChartView
|
||||
isOpen={showKvzModal}
|
||||
onClose={closeKvzModal}
|
||||
slotIndex={slotIndex}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
)}
|
||||
|
||||
{/* Früher inline Panel – jetzt eigenes Modal (KVZChartView) */}
|
||||
{/* {showKvzPanel && isKvzActiveForSlot && (
|
||||
<div className="flex flex-col items-center ">
|
||||
<FallSensors slotIndex={slotIndex} />
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Offline-View */}
|
||||
{kueOnline !== 1 && (
|
||||
<div className="flex items-center justify-center ">
|
||||
{/* Das soll rausgenommen werden
|
||||
<p>Kein Modul im Slot {slotIndex + 0}</p>
|
||||
*/}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAppSelector } from "@/redux/store";
|
||||
|
||||
export default function SlotActivityOverlay({
|
||||
slotIndex,
|
||||
}: {
|
||||
slotIndex: number;
|
||||
}) {
|
||||
const ksx = useAppSelector((s) => s.deviceEvents.ksx);
|
||||
const ksy = useAppSelector((s) => s.deviceEvents.ksy);
|
||||
const ksz = useAppSelector((s) => s.deviceEvents.ksz);
|
||||
const loopStartedAt = useAppSelector((s) => s.deviceEvents.loopStartedAt);
|
||||
const tdrStartedAt = useAppSelector((s) => s.deviceEvents.tdrStartedAt);
|
||||
const comparisonStartedAt = useAppSelector(
|
||||
(s) => s.deviceEvents.comparisonStartedAt
|
||||
);
|
||||
const loopStartedAtBySlot = useAppSelector(
|
||||
(s) => s.deviceEvents.loopStartedAtBySlot
|
||||
);
|
||||
const tdrStartedAtBySlot = useAppSelector(
|
||||
(s) => s.deviceEvents.tdrStartedAtBySlot
|
||||
);
|
||||
const comparisonStartedAtBySlot = useAppSelector(
|
||||
(s) => s.deviceEvents.comparisonStartedAtBySlot
|
||||
);
|
||||
|
||||
const loopActive = Array.isArray(ksx) && ksx[slotIndex] === 1;
|
||||
const tdrActive = Array.isArray(ksy) && ksy[slotIndex] === 1;
|
||||
const comparisonActive = Array.isArray(ksz) && ksz[slotIndex] === 1;
|
||||
|
||||
// Persist whenever arrays change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
"deviceEventsTimingsV1",
|
||||
JSON.stringify({
|
||||
loop: loopStartedAtBySlot,
|
||||
tdr: tdrStartedAtBySlot,
|
||||
compare: comparisonStartedAtBySlot,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to persist timings", e);
|
||||
}
|
||||
}, [loopStartedAtBySlot, tdrStartedAtBySlot, comparisonStartedAtBySlot]);
|
||||
|
||||
// Progress ticker
|
||||
const [now, setNow] = useState<number>(Date.now());
|
||||
useEffect(() => {
|
||||
const any = loopActive || tdrActive || comparisonActive;
|
||||
if (!any) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [loopActive, tdrActive, comparisonActive]);
|
||||
|
||||
const clamp = (v: number, min = 0, max = 1) =>
|
||||
Math.max(min, Math.min(max, v));
|
||||
const compute = (startedAt: number | null, durationMs: number) => {
|
||||
if (!startedAt) return { pct: 0 };
|
||||
const elapsed = now - startedAt;
|
||||
const pct = clamp(elapsed / durationMs) * 100;
|
||||
return { pct };
|
||||
};
|
||||
|
||||
// Durations
|
||||
const LOOP_MS = 2 * 60 * 1000; // ~2 min
|
||||
const TDR_MS = 110 * 1000; // ~2 min laut die Eigaben
|
||||
const ALIGN_MS = 10 * 60 * 1000; // ~10 min
|
||||
|
||||
if (!loopActive && !tdrActive && !comparisonActive) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-white/70 backdrop-blur-sm">
|
||||
<div className="p-2 rounded-md shadow bg-white/90 border border-gray-200 w-[min(90%,12rem)]">
|
||||
<div className="text-[0.75rem] font-semibold mb-2 text-gray-800">
|
||||
Bitte warten…
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{loopActive && (
|
||||
<div>
|
||||
<div className="text-[0.7rem] text-gray-800 mb-1">Schleife</div>
|
||||
{(() => {
|
||||
const started = loopStartedAtBySlot[slotIndex] ?? loopStartedAt;
|
||||
const { pct } = compute(started, LOOP_MS);
|
||||
return (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-gray-700 mt-1">
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{tdrActive && (
|
||||
<div>
|
||||
<div className="text-[0.7rem] text-gray-800 mb-1">TDR</div>
|
||||
{(() => {
|
||||
const started = tdrStartedAtBySlot[slotIndex] ?? tdrStartedAt;
|
||||
const { pct } = compute(started, TDR_MS);
|
||||
return (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-gray-700 mt-1">
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{comparisonActive && (
|
||||
<div>
|
||||
<div className="text-[0.7rem] text-gray-800 mb-1">Abgleich</div>
|
||||
{(() => {
|
||||
const started =
|
||||
comparisonStartedAtBySlot[slotIndex] ?? comparisonStartedAt;
|
||||
const { pct } = compute(started, ALIGN_MS);
|
||||
return (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-gray-200 rounded overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-littwin-blue transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[0.65rem] text-gray-700 mt-1">
|
||||
{Math.round(pct)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,32 @@
|
||||
// /komponents/main/kabelueberwachung/kue705FO/handlers/firmwareUpdate.ts
|
||||
const firmwareUpdate = (slot: number) => {
|
||||
// @/components/main/kabelueberwachung/kue705FO/handlers/firmwareUpdate.ts
|
||||
export default async function firmwareUpdate(
|
||||
slot: number
|
||||
): Promise<{ message: string }> {
|
||||
const isDev =
|
||||
typeof window !== "undefined" && window.location.hostname === "localhost";
|
||||
const url = isDev
|
||||
? `${window.location.origin}/api/cpl/kueSingleModuleUpdateMock?slot=${
|
||||
slot + 1
|
||||
}`
|
||||
: `${window.location.origin}/CPL?/kabelueberwachung.html&KSU${slot}=1`;
|
||||
: `${window.location.origin}/CPL?Service/ae.ACP&KSU${slot}=1`;
|
||||
|
||||
fetch(url, { method: "GET" })
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
alert(
|
||||
data.message || `Update an Slot ${slot + 1} erfolgreich gestartet!`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Fehler:", error);
|
||||
alert("Fehler beim Update!");
|
||||
});
|
||||
};
|
||||
try {
|
||||
const response = await fetch(url, { method: "GET" });
|
||||
|
||||
export default firmwareUpdate;
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fehler: Status ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
//alert(data.message || `Update an Slot ${slot + 1} erfolgreich gestartet!`);
|
||||
const message =
|
||||
data.message || `Update an Slot ${slot + 1} erfolgreich gestartet!`;
|
||||
console.log(message);
|
||||
return { message };
|
||||
} catch (error) {
|
||||
console.error("Fehler:", error);
|
||||
//alert("Fehler beim Update!");
|
||||
return { message: "Fehler beim Update!" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const handleOpenChartModal = (
|
||||
setShowChartModal: Dispatch<SetStateAction<boolean>>,
|
||||
dispatch: ReturnType<typeof useDispatch>,
|
||||
slotIndex: number,
|
||||
activeButton: "Schleife" | "TDR"
|
||||
activeButton: "Schleife" | "TDR" | "ISO"
|
||||
) => {
|
||||
setShowChartModal(true);
|
||||
dispatch(setChartOpen(true));
|
||||
@@ -26,6 +26,8 @@ const handleOpenChartModal = (
|
||||
|
||||
if (activeButton === "TDR") {
|
||||
dispatch(setActiveMode("TDR"));
|
||||
} else if (activeButton === "ISO") {
|
||||
dispatch(setActiveMode("ISO"));
|
||||
} else {
|
||||
dispatch(setActiveMode("Schleife"));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { goLoop } from "@/utils/goLoop";
|
||||
import { goTDR } from "@/utils/goTDR";
|
||||
|
||||
const handleRefreshClick = (
|
||||
activeButton: "Schleife" | "TDR",
|
||||
activeButton: "Schleife" | "TDR" | "ISO",
|
||||
slotIndex: number,
|
||||
setLoading: Dispatch<SetStateAction<boolean>>
|
||||
) => {
|
||||
@@ -13,6 +13,7 @@ const handleRefreshClick = (
|
||||
} else if (activeButton === "TDR") {
|
||||
goTDR(slotIndex, setLoading);
|
||||
}
|
||||
// ISO has no refresh functionality
|
||||
};
|
||||
|
||||
export default handleRefreshClick;
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// components/main/kabelueberwachung/kue705FO/hooks/useLoopDisplay.ts
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Keeps and updates the loop (RSL) display value only when "Schleife" active.
|
||||
// For ISO or TDR views we do not overwrite the displayed RSL value.
|
||||
const useLoopDisplay = (
|
||||
schleifenwiderstand: number,
|
||||
activeButton: "Schleife" | "TDR"
|
||||
rslValue: number,
|
||||
activeButton: "Schleife" | "TDR" | "ISO"
|
||||
) => {
|
||||
const [loopDisplayValue, setLoopDisplayValue] =
|
||||
useState<number>(schleifenwiderstand);
|
||||
const [loopDisplayValue, setLoopDisplayValue] = useState<number>(rslValue);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeButton === "Schleife") {
|
||||
setLoopDisplayValue(schleifenwiderstand);
|
||||
setLoopDisplayValue(rslValue);
|
||||
}
|
||||
}, [schleifenwiderstand, activeButton]);
|
||||
}, [rslValue, activeButton]);
|
||||
|
||||
return { loopDisplayValue, setLoopDisplayValue };
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"use client";
|
||||
|
||||
// components/main/kabelueberwachung/kue705FO/modals/KueEinstellung.tsx
|
||||
import { useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import type { RootState } from "../../../../../redux/store";
|
||||
import type { RootState, AppDispatch } from "@/redux/store";
|
||||
import handleSave from "../handlers/handleSave";
|
||||
import handleDisplayEinschalten from "../handlers/handleDisplayEinschalten";
|
||||
import handleDisplayEinschalten from "@/components/main/kabelueberwachung/kue705FO/handlers/handleDisplayEinschalten";
|
||||
import firmwareUpdate from "../handlers/firmwareUpdate";
|
||||
import { useAdminAuth } from "../../../settingsPageComponents/hooks/useAdminAuth";
|
||||
import ProgressModal from "@/components/main/settingsPageComponents/modals/ProgressModal";
|
||||
import { toast } from "react-toastify";
|
||||
import ConfirmModal from "@/components/common/ConfirmModal";
|
||||
import {
|
||||
openConfirmModal,
|
||||
closeConfirmModal,
|
||||
} from "@/redux/slices/confirmModalSlice";
|
||||
import { startFirmwareUpdateThunk } from "@/redux/thunks/startFirmwareUpdateThunk";
|
||||
|
||||
interface Props {
|
||||
slot: number;
|
||||
@@ -15,25 +22,12 @@ interface Props {
|
||||
onModulNameChange?: (id: string) => void;
|
||||
}
|
||||
|
||||
const memoryIntervalOptions = [
|
||||
{ value: 0, label: "Kein" },
|
||||
{ value: 1, label: "1 Minute" },
|
||||
{ value: 5, label: "5 Minuten" },
|
||||
{ value: 10, label: "10 Minuten" },
|
||||
{ value: 15, label: "15 Minuten" },
|
||||
{ value: 30, label: "30 Minuten" },
|
||||
{ value: 60, label: "60 Minuten" },
|
||||
{ value: 360, label: "6 Stunden" },
|
||||
{ value: 720, label: "12 Stunden" },
|
||||
];
|
||||
|
||||
export default function KueEinstellung({
|
||||
slot,
|
||||
|
||||
onClose = () => {},
|
||||
onModulNameChange,
|
||||
}: Props) {
|
||||
const dispatch = useDispatch();
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const {
|
||||
kueID,
|
||||
kueName,
|
||||
@@ -43,18 +37,27 @@ export default function KueEinstellung({
|
||||
kueLoopInterval,
|
||||
memoryInterval,
|
||||
} = useSelector((state: RootState) => state.kueDataSlice);
|
||||
const reduxAdmin = useSelector(
|
||||
(state: RootState) => state.authSlice.isAdminLoggedIn
|
||||
);
|
||||
const [isAdminLoggedIn] = useState(() => reduxAdmin);
|
||||
|
||||
const { isAdminLoggedIn } = useAdminAuth(true);
|
||||
const showConfirmModal = useSelector(
|
||||
(state: RootState) => state.confirmModal.open
|
||||
);
|
||||
|
||||
const formCacheKey = `slot_${slot}`;
|
||||
if (typeof window !== "undefined") {
|
||||
window.__kueCache = window.__kueCache || {};
|
||||
}
|
||||
const cached =
|
||||
typeof window !== "undefined" ? window.__kueCache?.[formCacheKey] : null;
|
||||
const isUpdating = useSelector(
|
||||
(state: RootState) => state.firmwareProgress.isUpdating
|
||||
);
|
||||
const progress = useSelector(
|
||||
(state: RootState) => state.firmwareProgress.progress
|
||||
);
|
||||
|
||||
const [formData, setFormData] = useState(() => {
|
||||
if (cached) return cached;
|
||||
if (typeof window !== "undefined") {
|
||||
const cache = window.__kueCache?.[`slot_${slot}`];
|
||||
if (cache) return cache;
|
||||
}
|
||||
return {
|
||||
kueID: kueID[slot] || "",
|
||||
kueName: kueName[slot] || "",
|
||||
@@ -70,17 +73,12 @@ export default function KueEinstellung({
|
||||
const updated = { ...formData, [key]: value };
|
||||
setFormData(updated);
|
||||
if (typeof window !== "undefined") {
|
||||
window.__kueCache![formCacheKey] = updated;
|
||||
window.__kueCache = window.__kueCache || {};
|
||||
window.__kueCache[`slot_${slot}`] = updated;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveWrapper = async () => {
|
||||
const updatedKueID = [...kueID];
|
||||
//updatedKueID[slot] = formData.kueID;
|
||||
/* if (Object.isFrozen(kueID)) {
|
||||
console.warn("kueID ist readonly!");
|
||||
}
|
||||
*/
|
||||
const updatedKueName = [...kueName];
|
||||
updatedKueName[slot] = formData.kueName;
|
||||
|
||||
@@ -100,7 +98,7 @@ export default function KueEinstellung({
|
||||
updatedMemoryInterval[slot] = Number(formData.memoryInterval);
|
||||
|
||||
const newData = {
|
||||
kueID: updatedKueID[slot],
|
||||
kueID: kueID[slot],
|
||||
kueName: updatedKueName[slot],
|
||||
limit1: updatedLimit1[slot].toString(),
|
||||
delay1: updatedDelay1[slot].toString(),
|
||||
@@ -108,13 +106,11 @@ export default function KueEinstellung({
|
||||
loopInterval: updatedLoopInterval[slot].toString(),
|
||||
memoryInterval: updatedMemoryInterval[slot].toString(),
|
||||
};
|
||||
|
||||
setFormData(newData);
|
||||
if (typeof window !== "undefined") {
|
||||
window.__kueCache![`slot_${slot}`] = newData;
|
||||
}
|
||||
|
||||
// 🔧 handleSave aufrufen mit allen Daten
|
||||
await handleSave({
|
||||
slot,
|
||||
ids: kueID,
|
||||
@@ -122,7 +118,7 @@ export default function KueEinstellung({
|
||||
isolationsgrenzwerte: updatedLimit1,
|
||||
verzoegerung: updatedDelay1,
|
||||
untereSchleifenGrenzwerte: updatedLimit2Low,
|
||||
obereSchleifenGrenzwerte: updatedLimit2Low, // ggf. anpassen, falls du später High-Werte brauchst
|
||||
obereSchleifenGrenzwerte: updatedLimit2Low,
|
||||
schleifenintervall: updatedLoopInterval,
|
||||
speicherintervall: updatedMemoryInterval,
|
||||
originalValues: {
|
||||
@@ -165,20 +161,23 @@ export default function KueEinstellung({
|
||||
/>
|
||||
</div>
|
||||
{/* Speicherintervall */}
|
||||
{/* Speicherintervall */}
|
||||
{/* Speicherintervall */}
|
||||
<div className="mb-4 grid grid-cols-3 items-center gap-2 w-full">
|
||||
<label className="">Speicherintervall:</label>
|
||||
<select
|
||||
className="w-full border rounded p-1"
|
||||
value={formData.memoryInterval}
|
||||
onChange={(e) => handleChange("memoryInterval", e.target.value)}
|
||||
>
|
||||
{memoryIntervalOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="w-48">Speicherintervall:</label>
|
||||
<div className="relative w-36">
|
||||
<input
|
||||
type="number"
|
||||
className="border rounded px-2 py-1 pr-20 w-full text-right"
|
||||
value={formData.memoryInterval}
|
||||
onChange={(e) => handleChange("memoryInterval", e.target.value)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-500 text-sm">
|
||||
Minuten
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Isolationsmessung */}
|
||||
<div className="mb-4 w-full">
|
||||
<h3 className="font-bold mb-2">Isolationsmessung</h3>
|
||||
@@ -243,22 +242,85 @@ export default function KueEinstellung({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 p-0 rounded">
|
||||
<div className="flex flex-wrap justify-end gap-2 p-0 rounded">
|
||||
{isAdminLoggedIn && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
"Warnung: Das Firmware-Update kann einige Minuten dauern und das Gerät neu starten.\nMöchten Sie wirklich fortfahren?"
|
||||
)
|
||||
) {
|
||||
firmwareUpdate(slot);
|
||||
<>
|
||||
<button
|
||||
onClick={() => dispatch(openConfirmModal())}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
Firmware Update
|
||||
</button>
|
||||
|
||||
{/* Konfiguration sichern */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch(
|
||||
`/CPL?kabelueberwachung.html&KSB${slot
|
||||
.toString()
|
||||
.padStart(2, "0")}=1`
|
||||
);
|
||||
toast.success("✅ Konfiguration gesichert.");
|
||||
} catch (err) {
|
||||
console.error("KSB Fehler", err);
|
||||
toast.error("❌ Fehler beim Sichern der Konfiguration");
|
||||
}
|
||||
}}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
Konfig. sichern
|
||||
</button>
|
||||
|
||||
{/* Konfiguration zurücksichern */}
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch(
|
||||
`/CPL?kabelueberwachung.html&KSR${slot
|
||||
.toString()
|
||||
.padStart(2, "0")}=1`
|
||||
);
|
||||
toast.success("✅ Konfiguration wiederhergestellt.");
|
||||
} catch (err) {
|
||||
console.error("KSR Fehler", err);
|
||||
toast.error(
|
||||
"❌ Fehler beim Wiederherstellen der Konfiguration"
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
Konfig. zurücksichern
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showConfirmModal && (
|
||||
<ConfirmModal
|
||||
open={showConfirmModal}
|
||||
title="Firmware-Update starten?"
|
||||
message="⚠️ Das Firmware-Update kann einige Minuten dauern. Möchten Sie wirklich fortfahren?"
|
||||
onCancel={() => dispatch(closeConfirmModal())}
|
||||
onConfirm={async () => {
|
||||
dispatch(closeConfirmModal());
|
||||
toast.info("Firmware-Update gestartet. Bitte warten...");
|
||||
dispatch(startFirmwareUpdateThunk(slot)); // Start Redux-Prozess
|
||||
|
||||
try {
|
||||
await firmwareUpdate(slot);
|
||||
} catch (err) {
|
||||
console.error("Firmware-Update-Fehler:", err);
|
||||
toast.error("❌ Fehler beim Firmwareupdate");
|
||||
}
|
||||
}}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
Firmware Update
|
||||
</button>
|
||||
/>
|
||||
)}
|
||||
{isUpdating && (
|
||||
<ProgressModal
|
||||
visible={isUpdating}
|
||||
progress={progress}
|
||||
slot={slot + 1}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDisplayEinschalten(slot)}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState, useAppDispatch } from "../../../../../redux/store";
|
||||
import { updateKvzData } from "../../../../../redux/thunks/kvzThunks";
|
||||
import { useAdminAuth } from "../../../settingsPageComponents/hooks/useAdminAuth";
|
||||
|
||||
type KvzData = {
|
||||
// Hier können später weitere KVz-spezifische Einstellungen hinzugefügt werden
|
||||
kvzSettings: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
slot: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export default function KvzModalView({ slot, onClose }: Props) {
|
||||
const { isAdminLoggedIn } = useAdminAuth(true);
|
||||
const dispatch = useAppDispatch();
|
||||
const kvzSlice = useSelector((state: RootState) => state.kueDataSlice);
|
||||
|
||||
// KVZ System: 32 Slots mit je 4 LEDs
|
||||
const isKvzPresent = kvzSlice.kvzPresence?.[slot] === 1;
|
||||
const isKvzActive = kvzSlice.kvzActive?.[slot] === 1;
|
||||
|
||||
// LED Status für diesen Slot (4 LEDs pro Slot)
|
||||
const getKvzLedStatus = (ledIndex: number) => {
|
||||
const arrayIndex = slot * 4 + ledIndex;
|
||||
return kvzSlice.kvzStatus?.[arrayIndex] === 1;
|
||||
};
|
||||
|
||||
const [localKvzActive, setLocalKvzActive] = useState(() => isKvzActive);
|
||||
|
||||
// Synchronisiere localState mit Redux State
|
||||
React.useEffect(() => {
|
||||
setLocalKvzActive(isKvzActive);
|
||||
}, [isKvzActive]);
|
||||
|
||||
const handleKvzToggle = async () => {
|
||||
const newState = !localKvzActive;
|
||||
setLocalKvzActive(newState);
|
||||
|
||||
try {
|
||||
// API Update mit neuem Thunk - kvzActive statt kvzPresence
|
||||
await dispatch(
|
||||
updateKvzData([{ key: "kvzActive", slot, value: newState ? 1 : 0 }])
|
||||
);
|
||||
|
||||
const msg = newState
|
||||
? "✅ KVz wurde aktiviert."
|
||||
: "⚠️ KVz wurde deaktiviert.";
|
||||
alert(msg);
|
||||
location.reload();
|
||||
} catch (error) {
|
||||
console.error("Fehler beim KVz-Toggle:", error);
|
||||
alert("Fehler beim Umschalten der KVz-Funktion.");
|
||||
// State zurücksetzen bei Fehler
|
||||
setLocalKvzActive(!newState);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 text-sm">
|
||||
{/* KVz-Funktion - nur anzeigen wenn KVZ vorhanden ist */}
|
||||
{isAdminLoggedIn && isKvzPresent && (
|
||||
<div className="mb-4 mt-4 grid grid-cols-3 items-center gap-2 w-full">
|
||||
<span className="text-sm font-medium">KVz-Funktion:</span>
|
||||
<div className="col-span-2 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={localKvzActive}
|
||||
onClick={handleKvzToggle}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200 ${
|
||||
localKvzActive ? "bg-littwin-blue" : "bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${
|
||||
localKvzActive ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-600">
|
||||
{localKvzActive ? "aktiviert" : "deaktiviert"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meldung wenn KVZ nicht vorhanden */}
|
||||
{!isKvzPresent && (
|
||||
<div className="mb-4 mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-600">⚠️</span>
|
||||
<p className="text-sm text-yellow-800">
|
||||
<strong>Kein KVZ-Gerät vorhanden</strong>
|
||||
<br />
|
||||
Für Slot {slot + 1} ist kein KVZ-Gerät installiert oder
|
||||
konfiguriert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zukünftige KVz-Einstellungen können hier hinzugefügt werden */}
|
||||
{!isAdminLoggedIn && (
|
||||
<div className="mt-6 mb-4">
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Nur Admin-Benutzer können diese Einstellungen ändern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speichern Button */}
|
||||
|
||||
{/* <div className="mt-36">
|
||||
<div className="flex justify-end gap-2 p-3 rounded">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded flex items-center"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useState, useEffect } from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import KueEinstellung from "./KueEinstellung";
|
||||
import TdrEinstellung from "./TdrEinstellung";
|
||||
import KvzModalView from "./KvzModalView";
|
||||
import Knotenpunkte from "./Knotenpunkte";
|
||||
|
||||
interface KueModalProps {
|
||||
@@ -14,18 +15,20 @@ interface KueModalProps {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__lastKueTab?: "kue" | "tdr" | "knoten";
|
||||
__lastKueTab?: "kue" | "tdr" | "kvz" | "knoten";
|
||||
kabelModalOpen?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export default function KueModal({ showModal, onClose, slot }: KueModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"kue" | "tdr" | "knoten">(() => {
|
||||
if (typeof window !== "undefined" && window.__lastKueTab) {
|
||||
return window.__lastKueTab;
|
||||
const [activeTab, setActiveTab] = useState<"kue" | "tdr" | "kvz" | "knoten">(
|
||||
() => {
|
||||
if (typeof window !== "undefined" && window.__lastKueTab) {
|
||||
return window.__lastKueTab;
|
||||
}
|
||||
return "kue";
|
||||
}
|
||||
return "kue";
|
||||
});
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -43,57 +46,78 @@ export default function KueModal({ showModal, onClose, slot }: KueModalProps) {
|
||||
<ReactModal
|
||||
isOpen={showModal}
|
||||
onRequestClose={onClose}
|
||||
shouldCloseOnOverlayClick
|
||||
ariaHideApp={false}
|
||||
style={{
|
||||
overlay: {
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
backgroundColor: "rgba(0,0,0,0.55)",
|
||||
zIndex: 100,
|
||||
backdropFilter: "blur(2px)",
|
||||
},
|
||||
content: {
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
inset: "50% auto auto 50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
width: "90%",
|
||||
maxWidth: "850px",
|
||||
padding: "0px",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
position: "relative",
|
||||
bottom: "auto",
|
||||
right: "auto",
|
||||
width: "min(900px,92vw)",
|
||||
// Feste / konsistente Höhe, unabhängig vom Tab-Inhalt
|
||||
// Wenn Viewport kleiner ist, begrenze auf 80vh
|
||||
height: "min(640px, 80vh)",
|
||||
maxHeight: "80vh",
|
||||
padding: 0,
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "12px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
contentLabel={`Einstellungen KÜ ${slot + 1}`}
|
||||
>
|
||||
<div className="p-2 flex justify-between items-center rounded-t-md">
|
||||
<h2 className="text-base font-bold">
|
||||
Einstellungen Steckplatz {slot + 1}
|
||||
<div className="modal-header">
|
||||
<h2 className="text-sm font-semibold tracking-wide text-fg">
|
||||
Einstellungen KÜ {slot + 1}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-2xl hover:text-gray-200">
|
||||
<i className="bi bi-x-circle-fill"></i>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="icon-btn"
|
||||
aria-label="Modal schließen"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
fontSize: "1.5rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
className="bi bi-x-circle-fill"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start bg-gray-100 space-x-2 p-2">
|
||||
<div className="flex justify-start bg-surface-alt px-3 pt-2 gap-2 border-b border-base">
|
||||
{[
|
||||
{ label: "Allgemein", key: "kue" as const },
|
||||
{ label: "TDR ", key: "tdr" as const },
|
||||
{ label: "TDR", key: "tdr" as const },
|
||||
{ label: "KVz", key: "kvz" as const },
|
||||
{ label: "Knotenpunkte", key: "knoten" as const },
|
||||
].map(({ label, key }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`px-4 py-1 rounded-t font-bold text-sm ${
|
||||
activeTab === key
|
||||
? "bg-white text-littwin-blue"
|
||||
: "text-gray-500 hover:text-black"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
].map(({ label, key }) => {
|
||||
const isActive = activeTab === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`tab-btn ${isActive ? "tab-btn-active" : ""}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-white rounded-b-md h-[20rem] laptop:h-[24rem] 2xl:h-[30rem] overflow-y-auto">
|
||||
{/* Einheitliche Body-Höhe mit internem Scroll statt dynamischer Außenhöhe */}
|
||||
<div className="modal-body-scroll px-5 py-4 flex-1 text-fg overflow-y-auto">
|
||||
{activeTab === "kue" && (
|
||||
<KueEinstellung
|
||||
slot={slot}
|
||||
@@ -105,6 +129,7 @@ export default function KueModal({ showModal, onClose, slot }: KueModalProps) {
|
||||
{activeTab === "tdr" && (
|
||||
<TdrEinstellung slot={slot} onClose={onClose} />
|
||||
)}
|
||||
{activeTab === "kvz" && <KvzModalView slot={slot} onClose={onClose} />}
|
||||
{activeTab === "knoten" && (
|
||||
<Knotenpunkte slot={slot} onClose={onClose} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
// components/main/kabelueberwachung/kue705FO/modals/SuccessProgressModal.tsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
duration?: number; // in Sekunden
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SuccessProgressModal: React.FC<Props> = ({
|
||||
visible,
|
||||
duration = 10,
|
||||
onClose,
|
||||
}) => {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setProgress(0);
|
||||
const interval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 100) {
|
||||
clearInterval(interval);
|
||||
setTimeout(onClose, 500); // Schließen nach kurzer Verzögerung
|
||||
return 100;
|
||||
}
|
||||
return prev + 100 / duration;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [visible, duration, onClose]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black bg-opacity-40 flex items-center justify-center">
|
||||
<div className="bg-white p-6 rounded-lg shadow-md text-center w-72">
|
||||
<h2 className="text-lg font-bold text-green-600 mb-4">
|
||||
✅ Firmwareupdate erfolgreich abgeschlossen.
|
||||
</h2>
|
||||
<div className="w-full bg-gray-200 rounded h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-3 bg-green-500 transition-all duration-100"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm mt-2">{Math.floor(progress)}%</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuccessProgressModal;
|
||||
@@ -12,8 +12,9 @@ declare global {
|
||||
}
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { RootState } from "../../../../../redux/store";
|
||||
import { setKueData } from "../../../../../redux/slices/kueDataSlice";
|
||||
|
||||
import { useAdminAuth } from "../../../settingsPageComponents/hooks/useAdminAuth";
|
||||
|
||||
@@ -24,6 +25,7 @@ interface Props {
|
||||
|
||||
export default function TdrEinstellung({ slot, onClose }: Props) {
|
||||
const { isAdminLoggedIn } = useAdminAuth(true);
|
||||
const dispatch = useDispatch();
|
||||
const tdrSlice = useSelector((state: RootState) => state.kueDataSlice);
|
||||
|
||||
const cacheKey = `slot_${slot}`;
|
||||
@@ -126,6 +128,11 @@ export default function TdrEinstellung({ slot, onClose }: Props) {
|
||||
setTdrActive(newState);
|
||||
updateCache(tdrData, newState);
|
||||
|
||||
// Redux State sofort aktualisieren für UI-Update
|
||||
const updatedTdrActive = [...(tdrSlice.tdrActive || [])];
|
||||
updatedTdrActive[slot] = newState ? 1 : 0;
|
||||
dispatch(setKueData({ tdrActive: updatedTdrActive }));
|
||||
|
||||
const isDev = window.location.hostname === "localhost";
|
||||
const slotParam = `KTX${slot}=${newState ? 1 : 0}`;
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ const DateRangePickerMeldungen: React.FC<Props> = ({
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm font-semibold">Von</label>
|
||||
<DatePicker
|
||||
portalId="root-portal" // beliebige ID
|
||||
popperClassName="custom-datepicker-popper"
|
||||
selected={parseISO(fromDate)}
|
||||
onChange={(date) => date && setFromDate(formatDate(date))}
|
||||
selectsStart
|
||||
@@ -42,6 +44,8 @@ const DateRangePickerMeldungen: React.FC<Props> = ({
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-sm font-semibold">Bis</label>
|
||||
<DatePicker
|
||||
portalId="root-portal" // beliebige ID
|
||||
popperClassName="custom-datepicker-popper"
|
||||
selected={parseISO(toDate)}
|
||||
onChange={(date) => date && setToDate(formatDate(date))}
|
||||
selectsEnd
|
||||
|
||||
79
components/main/reports/MeldungenTabelle.tsx
Normal file
79
components/main/reports/MeldungenTabelle.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
type Meldung = {
|
||||
t: string;
|
||||
s: number;
|
||||
c: string;
|
||||
m: string;
|
||||
i: string;
|
||||
v: string;
|
||||
};
|
||||
|
||||
export default function MeldungenTabelle({
|
||||
messages,
|
||||
}: {
|
||||
messages: Meldung[];
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-auto max-h-[80vh]">
|
||||
<table className="min-w-full border border-base table-surface text-fg">
|
||||
<thead className="text-left sticky top-0 z-10 bg-surface-alt/90 backdrop-blur supports-[backdrop-filter]:bg-surface-alt/70">
|
||||
<tr>
|
||||
<th className="p-2 border border-base bg-surface-alt text-fg font-medium">
|
||||
Prio
|
||||
</th>
|
||||
<th className="p-2 border border-base bg-surface-alt text-fg font-medium">
|
||||
Zeitstempel
|
||||
</th>
|
||||
<th className="p-2 border border-base bg-surface-alt text-fg font-medium">
|
||||
Quelle
|
||||
</th>
|
||||
<th className="p-2 border border-base bg-surface-alt text-fg font-medium">
|
||||
Meldung
|
||||
</th>
|
||||
<th className="p-2 border border-base bg-surface-alt text-fg font-medium">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{messages.map((msg, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className="transition-colors hover:bg-surface-alt/60"
|
||||
>
|
||||
<td className="border border-base p-2 bg-surface text-fg">
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: msg.c }}
|
||||
></div>
|
||||
</td>
|
||||
<td className="border border-base p-2 bg-surface text-fg">
|
||||
{new Date(msg.t).toLocaleString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
})}
|
||||
</td>
|
||||
<td className="border border-base p-2 bg-surface text-fg">
|
||||
{msg.i}
|
||||
</td>
|
||||
<td className="border border-base p-2 bg-surface text-fg">
|
||||
{msg.m}
|
||||
</td>
|
||||
<td className="border border-base p-2 bg-surface text-fg">
|
||||
{msg.v}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{messages.length === 0 && (
|
||||
<div className="mt-4 text-center text-fg-muted italic">
|
||||
Keine Meldungen im gewählten Zeitraum vorhanden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
components/main/reports/MeldungenView.tsx
Normal file
114
components/main/reports/MeldungenView.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
// components/main/reports/MeldungenView.tsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { getMessagesThunk } from "@/redux/thunks/getMessagesThunk";
|
||||
import type { AppDispatch } from "@/redux/store";
|
||||
import type { RootState } from "@/redux/store";
|
||||
import DateRangePickerMeldungen from "./DateRangePickerMeldungen";
|
||||
import MeldungenTabelle from "./MeldungenTabelle";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
|
||||
type Meldung = {
|
||||
t: string;
|
||||
s: number;
|
||||
c: string;
|
||||
m: string;
|
||||
i: string;
|
||||
v: string;
|
||||
};
|
||||
|
||||
export default function MeldungenView() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const messages = useSelector((state: RootState) => state.messages.data);
|
||||
|
||||
const [sourceFilter, setSourceFilter] = useState("Alle Quellen");
|
||||
|
||||
const today = new Date();
|
||||
const prior30 = new Date();
|
||||
prior30.setDate(today.getDate() - 30);
|
||||
|
||||
const formatDate = (d: Date) => d.toISOString().split("T")[0];
|
||||
|
||||
const [fromDate, setFromDate] = useState<string>(formatDate(prior30));
|
||||
const [toDate, setToDate] = useState<string>(formatDate(today));
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getMessagesThunk({ fromDate, toDate }));
|
||||
}, []);
|
||||
|
||||
const filteredMessages =
|
||||
sourceFilter === "Alle Quellen"
|
||||
? messages
|
||||
: messages.filter((m: Meldung) => m.i === sourceFilter);
|
||||
|
||||
const allSources = Array.from(
|
||||
new Set(messages.map((m: Meldung) => m.i))
|
||||
).sort();
|
||||
const sources = ["Alle Quellen", ...allSources];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 h-[calc(100vh-13vh-8vh)]">
|
||||
<h1 className="text-xl font-bold mb-4">Berichte</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-6 mb-6 items-center">
|
||||
<DateRangePickerMeldungen
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
setFromDate={setFromDate}
|
||||
setToDate={setToDate}
|
||||
/>
|
||||
<button
|
||||
onClick={() => dispatch(getMessagesThunk({ fromDate, toDate }))}
|
||||
className="btn-primary px-4 py-2 h-fit"
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
|
||||
<Listbox value={sourceFilter} onChange={setSourceFilter}>
|
||||
<div className="relative ml-6 w-64">
|
||||
<Listbox.Button className="bg-[var(--color-surface)] text-[var(--color-fg)] w-full border border-base px-4 py-2 rounded text-left flex justify-between items-center">
|
||||
<span>{sourceFilter}</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.585l3.71-3.355a.75.75 0 111.02 1.1l-4.25 3.85a.75.75 0 01-1.02 0l-4.25-3.85a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="bg-[var(--color-surface)] absolute z-50 mt-1 w-full border border-base rounded shadow-sm">
|
||||
{sources.map((src) => (
|
||||
<Listbox.Option
|
||||
key={src}
|
||||
value={src}
|
||||
className={({ selected, active, disabled }) =>
|
||||
`px-4 py-2 cursor-pointer text-[var(--color-fg)] transition-colors ${
|
||||
disabled
|
||||
? "opacity-50 text-[var(--color-muted)] cursor-not-allowed"
|
||||
: selected
|
||||
? "bg-accent text-white"
|
||||
: active
|
||||
? "bg-[var(--color-surface-alt)]"
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
{src}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<MeldungenTabelle messages={filteredMessages} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import { useAdminAuth } from "./hooks/useAdminAuth";
|
||||
const DatabaseSettings: React.FC = () => {
|
||||
const { isAdminLoggedIn } = useAdminAuth(true);
|
||||
return (
|
||||
<div className="p-6 bg-gray-100 max-w-5xl mr-auto rounded shadow">
|
||||
<div className="p-6 bg-[var(--color-surface-alt)] max-w-5xl mr-auto rounded shadow text-[var(--color-fg)]">
|
||||
<h2 className="text-lg font-bold mb-6">Datenbank Einstellungen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearMessages}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded shadow "
|
||||
className="btn-accent px-4 py-2 rounded shadow"
|
||||
>
|
||||
Meldungen löschen
|
||||
</button>
|
||||
@@ -25,7 +25,7 @@ const DatabaseSettings: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearLogger}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded shadow "
|
||||
className="btn-accent px-4 py-2 rounded shadow"
|
||||
>
|
||||
Messwerte Logger löschen
|
||||
</button>
|
||||
@@ -41,7 +41,7 @@ const DatabaseSettings: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearDatabase}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded shadow "
|
||||
className="btn-accent px-4 py-2 rounded shadow"
|
||||
>
|
||||
Datenbank vollständig leeren
|
||||
</button>
|
||||
@@ -49,7 +49,7 @@ const DatabaseSettings: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearConfig}
|
||||
className="bg-littwin-blue text-white px-4 py-2 rounded shadow "
|
||||
className="btn-accent px-4 py-2 rounded shadow"
|
||||
>
|
||||
Konfiguration löschen
|
||||
</button>
|
||||
|
||||
@@ -13,12 +13,17 @@ import { getSystemSettingsThunk } from "../../../redux/thunks/getSystemSettingsT
|
||||
import handleGeneralSubmit from "./handlers/handleGeneralSubmit";
|
||||
import handleKueFirmwareUpdate from "@/components/main/settingsPageComponents/handlers/handleKueFirmwareUpdate";
|
||||
import { useAdminAuth } from "@/components/main/settingsPageComponents/hooks/useAdminAuth";
|
||||
import ProgressModal from "@/components/main/settingsPageComponents/modals/ProgressModal";
|
||||
|
||||
import "react-toastify/dist/ReactToastify.css";
|
||||
|
||||
const GeneralSettings: React.FC = () => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const systemSettings = useSelector(
|
||||
(state: RootState) => state.systemSettingsSlice
|
||||
);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
const { isAdminLoggedIn } = useAdminAuth(true);
|
||||
|
||||
@@ -58,8 +63,11 @@ const GeneralSettings: React.FC = () => {
|
||||
setMac1(systemSettings.mac1 || "");
|
||||
}, [systemSettings]);
|
||||
|
||||
const inputCls =
|
||||
"border border-base focus:border-accent rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none";
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-3 bg-gray-100 max-w-5xl mr-auto overflow-y-auto max-h-[calc(100vh-200px)] ">
|
||||
<div className="p-6 md:p-3 bg-[var(--color-surface-alt)] max-w-5xl mr-auto overflow-y-auto max-h-[calc(100vh-200px)] text-[var(--color-fg)]">
|
||||
<h2 className="text-sm md:text-md font-bold mb-2">
|
||||
Allgemeine Einstellungen
|
||||
</h2>
|
||||
@@ -69,25 +77,18 @@ const GeneralSettings: React.FC = () => {
|
||||
<label className="block text-xs md:text-sm font-medium">Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className={inputCls}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MAC Adresse */}
|
||||
<div>
|
||||
<label className="block text-xs md:text-sm font-medium">
|
||||
MAC Adresse 1:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
value={mac1}
|
||||
disabled
|
||||
/>
|
||||
<input type="text" className={inputCls} value={mac1} disabled />
|
||||
</div>
|
||||
|
||||
{/* Systemzeit */}
|
||||
<div className="col-span-2">
|
||||
<label className="block text-xs md:text-sm font-medium mb-1">
|
||||
@@ -96,13 +97,13 @@ const GeneralSettings: React.FC = () => {
|
||||
<div className="flex flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className={inputCls}
|
||||
value={systemUhr.replace(/\s*Uhr$/, "")}
|
||||
disabled
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
className="btn-accent px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={() => handleSetDateTime()}
|
||||
>
|
||||
Systemzeit übernehmen
|
||||
@@ -115,7 +116,7 @@ const GeneralSettings: React.FC = () => {
|
||||
<label className="block text-xs md:text-sm font-medium">IP:</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className={inputCls}
|
||||
value={ip}
|
||||
onChange={(e) => setIp(e.target.value)}
|
||||
/>
|
||||
@@ -126,7 +127,7 @@ const GeneralSettings: React.FC = () => {
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className={inputCls}
|
||||
value={subnet}
|
||||
onChange={(e) => setSubnet(e.target.value)}
|
||||
/>
|
||||
@@ -137,61 +138,17 @@ const GeneralSettings: React.FC = () => {
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className={inputCls}
|
||||
value={gateway}
|
||||
onChange={(e) => setGateway(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Admin Login */}
|
||||
{/*
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
{isAdminLoggedIn ? (
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={logoutAdmin}
|
||||
>
|
||||
Admin abmelden
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-row gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Benutzername"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={handleLogin}
|
||||
>
|
||||
Admin anmelden
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
*/}
|
||||
|
||||
{/* Feedback */}
|
||||
{/* You can add feedback here if needed */}
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="col-span-2 flex flex-wrap md:justify-between gap-1 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
className="btn-accent px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={() => handleReboot()}
|
||||
>
|
||||
Neustart CPL
|
||||
@@ -199,13 +156,51 @@ const GeneralSettings: React.FC = () => {
|
||||
{isAdminLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
className="btn-accent px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={() => {
|
||||
const confirmed = window.confirm(
|
||||
"⚠️ Wollen Sie wirklich ein Firmwareupdate für alle KÜ-Module starten?"
|
||||
);
|
||||
if (confirmed) {
|
||||
handleKueFirmwareUpdate();
|
||||
setIsUpdating(true);
|
||||
setProgress(0);
|
||||
|
||||
const updateDuration = 300; // Sekunden (5 Minuten)
|
||||
const intervalMs = 1000;
|
||||
let elapsed = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
elapsed++;
|
||||
const newProgress = Math.min(
|
||||
(elapsed / updateDuration) * 100,
|
||||
100
|
||||
);
|
||||
setProgress(newProgress);
|
||||
if (elapsed >= updateDuration) {
|
||||
clearInterval(interval);
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
handleKueFirmwareUpdate()
|
||||
.then(() => {
|
||||
clearInterval(interval);
|
||||
setProgress(100);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsUpdating(false);
|
||||
setProgress(100);
|
||||
|
||||
setTimeout(() => {
|
||||
alert("✅ Firmwareupdate erfolgreich abgeschlossen.");
|
||||
}, 300); // Nach Modal-Schließung
|
||||
}, 500);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Update-Fehler:", error);
|
||||
clearInterval(interval);
|
||||
setIsUpdating(false);
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -213,6 +208,8 @@ const GeneralSettings: React.FC = () => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ProgressModal visible={isUpdating} progress={progress} />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
|
||||
@@ -27,7 +27,7 @@ const NTPSettings: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-3 bg-gray-100 max-w-5xl mr-auto">
|
||||
<div className="p-6 md:p-3 bg-[var(--color-surface-alt)] max-w-5xl mr-auto text-[var(--color-fg)]">
|
||||
<h2 className="text-sm md:text-md font-bold mb-4">NTP Einstellungen</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
@@ -35,7 +35,7 @@ const NTPSettings: React.FC = () => {
|
||||
<label className="block text-xs font-medium">NTP Server 1</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className="border border-base rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={ntp1}
|
||||
onChange={(e) => setNtp1(e.target.value)}
|
||||
/>
|
||||
@@ -45,7 +45,7 @@ const NTPSettings: React.FC = () => {
|
||||
<label className="block text-xs font-medium">NTP Server 2</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className="border border-base rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={ntp2}
|
||||
onChange={(e) => setNtp2(e.target.value)}
|
||||
/>
|
||||
@@ -55,7 +55,7 @@ const NTPSettings: React.FC = () => {
|
||||
<label className="block text-xs font-medium">NTP Server 3</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className="border border-base rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={ntp3}
|
||||
onChange={(e) => setNtp3(e.target.value)}
|
||||
/>
|
||||
@@ -65,18 +65,19 @@ const NTPSettings: React.FC = () => {
|
||||
<label className="block text-xs font-medium">Zeitzone</label>
|
||||
<input
|
||||
type="text"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className="border border-base rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={ntpTimezone}
|
||||
onChange={(e) => setNtpTimezone(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 flex items-center gap-2 mt-2">
|
||||
<label className="text-xs font-medium">NTP aktiv:</label>
|
||||
<label className="text-xs font-medium ">NTP aktiv:</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={active}
|
||||
onChange={(e) => setActive(e.target.checked)}
|
||||
className="accent-littwin-blue w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ export default function OPCUAInterfaceSettings() {
|
||||
(state: RootState) => state.opcuaSettingsSlice
|
||||
);
|
||||
|
||||
// Anzahl der aktuellen OPC-Clients (Mock, bis Backend liefert)
|
||||
const opcUaActiveClientCount = opcuaSettings.opcUaActiveClientCount ?? 3; // 3 als Beispielwert
|
||||
|
||||
// Lokale Zustände für das neue Benutzerformular
|
||||
|
||||
const [nodesetName, setNodesetName] = useState(
|
||||
@@ -18,7 +21,7 @@ export default function OPCUAInterfaceSettings() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-3 bg-gray-100 max-w-5xl mr-auto ">
|
||||
<div className="p-6 md:p-3 bg-[var(--color-surface-alt)] max-w-5xl mr-auto text-[var(--color-fg)]">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<Image
|
||||
src="/images/OPCUA.jpg"
|
||||
@@ -41,9 +44,11 @@ export default function OPCUAInterfaceSettings() {
|
||||
<label className="mr-3 font-medium text-sm">Server Status:</label>
|
||||
<button
|
||||
onClick={() => dispatch(toggleOpcUaServer())}
|
||||
className={`px-3 py-1 rounded text-sm ${
|
||||
opcuaSettings.isEnabled ? "bg-littwin-blue" : "bg-gray-300"
|
||||
} text-white`}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors text-white ${
|
||||
opcuaSettings.isEnabled
|
||||
? "bg-accent hover:brightness-110"
|
||||
: "bg-[var(--color-muted)] hover:bg-[var(--color-fg)]/20"
|
||||
}`}
|
||||
>
|
||||
{opcuaSettings.isEnabled ? "Aktiviert" : "Deaktiviert"}
|
||||
</button>
|
||||
@@ -59,7 +64,7 @@ export default function OPCUAInterfaceSettings() {
|
||||
<select
|
||||
value={opcuaSettings.encryption}
|
||||
onChange={(e) => dispatch(setOpcUaEncryption(e.target.value))}
|
||||
className="w-full p-1 border border-gray-300 rounded-md text-sm"
|
||||
className="w-full p-1 border border-base rounded-md text-sm bg-[var(--color-surface)] text-[var(--color-fg)]"
|
||||
>
|
||||
<option value="None">Keine</option>
|
||||
<option value="Basic256">Basic256</option>
|
||||
@@ -71,7 +76,7 @@ export default function OPCUAInterfaceSettings() {
|
||||
{/* ✅ OPCUA Zustand */}
|
||||
<div className="mb-3">
|
||||
<label className="block font-medium text-sm mb-1">OPCUA Zustand</label>
|
||||
<div className="p-1 border border-gray-300 rounded-md bg-white text-sm">
|
||||
<div className="p-1 border border-base rounded-md bg-[var(--color-surface)] text-sm text-[var(--color-fg)]">
|
||||
{opcuaSettings.opcUaZustand}
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,22 +87,32 @@ export default function OPCUAInterfaceSettings() {
|
||||
<div className="flex">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-grow p-1 border border-gray-300 rounded-l-md text-sm"
|
||||
className="flex-grow p-1 border border-base rounded-l-md text-sm bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={nodesetName}
|
||||
onChange={(e) => setNodesetName(e.target.value)}
|
||||
disabled={opcuaSettings.isEnabled} // Disable input when server is enabled
|
||||
/>
|
||||
{/*
|
||||
<button
|
||||
onClick={handleNodesetUpdate}
|
||||
className="px-3 py-1 bg-littwin-blue text-white rounded-r-md text-sm"
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNodesetUpdate}
|
||||
className="px-3 py-1 bg-littwin-blue text-white rounded-r-md text-sm"
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Anzahl der aktuellen OPC-Clients */}
|
||||
<div className="mb-3">
|
||||
<label className="block font-medium text-sm mb-1">
|
||||
Aktuelle OPC-Clients
|
||||
</label>
|
||||
<div className="p-1 border border-base rounded-md bg-[var(--color-surface)] text-sm text-[var(--color-fg)]">
|
||||
{opcUaActiveClientCount}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ Benutzerverwaltung */}
|
||||
{/*
|
||||
|
||||
@@ -107,12 +122,12 @@ export default function OPCUAInterfaceSettings() {
|
||||
{opcuaSettings.users.map((user) => (
|
||||
<li
|
||||
key={user.id}
|
||||
className="p-1 bg-white shadow-sm rounded-md flex justify-between items-center text-sm"
|
||||
className="p-1 bg-[var(--color-surface)] border border-base rounded-md flex justify-between items-center text-sm text-[var(--color-fg)]"
|
||||
>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
<button
|
||||
onClick={() => dispatch(removeOpcUaUser(user.id))}
|
||||
className="text-red-500"
|
||||
className="text-danger hover:underline"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
@@ -127,18 +142,18 @@ export default function OPCUAInterfaceSettings() {
|
||||
placeholder="Benutzername"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="p-1 border rounded flex-grow text-sm"
|
||||
className="p-1 border border-base rounded flex-grow text-sm bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="p-1 border rounded flex-grow text-sm"
|
||||
className="p-1 border border-base rounded flex-grow text-sm bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddUser}
|
||||
className="bg-littwin-blue text-white p-1 rounded text-sm"
|
||||
className="btn-primary p-1 rounded text-sm"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
|
||||
85
components/main/settingsPageComponents/SettingsView.tsx
Normal file
85
components/main/settingsPageComponents/SettingsView.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
// components/main/settingsPageComponents/SettingsView.tsx
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useAppDispatch } from "@/redux/store";
|
||||
import { getSystemSettingsThunk } from "@/redux/thunks/getSystemSettingsThunk";
|
||||
import GeneralSettings from "./GeneralSettings";
|
||||
import OPCUAInterfaceSettings from "./OPCUAInterfaceSettings";
|
||||
import DatabaseSettings from "./DatabaseSettings";
|
||||
import NTPSettings from "./NTPSettings";
|
||||
import UserManagementSettings from "./UserManagementSettings";
|
||||
|
||||
export default function SettingsView() {
|
||||
const [activeTab, setActiveTab] = useState("tab1");
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getSystemSettingsThunk());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
activeTab === "tab1"
|
||||
? "border-b-2 border-littwin-blue text-littwin-blue"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("tab1")}
|
||||
>
|
||||
Allgemeine Einstellungen
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
activeTab === "tab2"
|
||||
? "border-b-2 border-littwin-blue text-littwin-blue"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("tab2")}
|
||||
>
|
||||
OPCUA
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
activeTab === "tab3"
|
||||
? "border-b-2 border-littwin-blue text-littwin-blue"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("tab3")}
|
||||
>
|
||||
Datenbank
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
activeTab === "tab4"
|
||||
? "border-b-2 border-littwin-blue text-littwin-blue"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("tab4")}
|
||||
>
|
||||
NTP
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 ${
|
||||
activeTab === "tab5"
|
||||
? "border-b-2 border-littwin-blue text-littwin-blue"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => setActiveTab("tab5")}
|
||||
>
|
||||
Benutzerverwaltung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{activeTab === "tab1" && <GeneralSettings />}
|
||||
{activeTab === "tab2" && <OPCUAInterfaceSettings />}
|
||||
{activeTab === "tab3" && <DatabaseSettings />}
|
||||
{activeTab === "tab4" && <NTPSettings />}
|
||||
{activeTab === "tab5" && <UserManagementSettings />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,12 @@ const UserManagementSettings: React.FC = () => {
|
||||
() => {
|
||||
setLoginSuccess(true);
|
||||
setError("");
|
||||
// Speichere die System-Uhrzeit (Login-Zeitpunkt) im localStorage
|
||||
try {
|
||||
localStorage.setItem("adminLoginTime", new Date().toISOString());
|
||||
} catch {
|
||||
// Ignoriere Speicherfehler (z. B. in Private Mode)
|
||||
}
|
||||
},
|
||||
(errorMsg) => {
|
||||
setLoginSuccess(false);
|
||||
@@ -31,8 +37,14 @@ const UserManagementSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-3 bg-gray-100 max-w-5xl mr-auto">
|
||||
<div className="p-6 md:p-3 bg-[var(--color-surface-alt)] max-w-5xl mr-auto text-[var(--color-fg)]">
|
||||
<h2 className="text-sm md:text-md font-bold mb-4">Login Admin-Bereich</h2>
|
||||
|
||||
{/* Admin Login/Logout */}
|
||||
@@ -40,8 +52,15 @@ const UserManagementSettings: React.FC = () => {
|
||||
{isAdminLoggedIn ? (
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={logoutAdmin}
|
||||
className="btn-accent px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={() => {
|
||||
try {
|
||||
localStorage.removeItem("adminLoginTime");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
logoutAdmin();
|
||||
}}
|
||||
>
|
||||
Admin abmelden
|
||||
</button>
|
||||
@@ -51,20 +70,22 @@ const UserManagementSettings: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Benutzername"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className="border border-base rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Passwort"
|
||||
className="border border-gray-300 rounded h-8 p-1 w-full text-xs"
|
||||
className="border border-base rounded h-8 p-1 w-full text-xs bg-[var(--color-surface)] text-[var(--color-fg)] placeholder-[var(--color-muted)] transition-colors duration-150 focus:outline-none"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="bg-littwin-blue text-white px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
className="btn-accent px-4 py-2 h-8 text-xs rounded whitespace-nowrap"
|
||||
onClick={handleLogin}
|
||||
>
|
||||
Admin anmelden
|
||||
@@ -75,9 +96,9 @@ const UserManagementSettings: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{loginSuccess && (
|
||||
<p className="text-green-600 text-xs mt-2">Login erfolgreich!</p>
|
||||
<p className="text-success text-xs mt-2">Login erfolgreich!</p>
|
||||
)}
|
||||
{error && <p className="text-red-500 text-xs mt-2">{error}</p>}
|
||||
{error && <p className="text-danger text-xs mt-2">{error}</p>}
|
||||
|
||||
{/*
|
||||
// Benutzerverwaltungstabelle (kommt später)
|
||||
|
||||
@@ -11,7 +11,7 @@ const handleKueFirmwareUpdate = async () => {
|
||||
const result = await res.text();
|
||||
|
||||
console.log("Firmwareupdate gesendet:", result);
|
||||
alert("Firmwareupdate wurde an alle KÜ-Module gesendet.");
|
||||
// alert("Firmwareupdate wurde an alle KÜ-Module gesendet.");
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Firmwareupdate:", error);
|
||||
alert("Fehler beim Firmwareupdate.");
|
||||
|
||||
@@ -29,6 +29,17 @@ export function useAdminAuth(showModal: boolean) {
|
||||
function logoutAdmin() {
|
||||
sessionStorage.removeItem("token");
|
||||
localStorage.setItem("isAdminLoggedIn", "false");
|
||||
|
||||
// KVz localStorage-Werte löschen für alle Slots
|
||||
const keysToRemove = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && key.startsWith("kvz_slot_")) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach((key) => localStorage.removeItem(key));
|
||||
|
||||
setAdminLoggedIn(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
// @/components/main/settingsPageComponents/modals/ProgressModal.tsx
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
progress: number;
|
||||
slot?: number;
|
||||
};
|
||||
|
||||
const ProgressModal: React.FC<Props> = ({ visible, progress, slot }) => {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ">
|
||||
<div className="p-6 rounded shadow-sm text-center w-80 bg-[var(--color-surface)] dark:bg-[var(--color-surface)] text-[var(--color-fg)] border border-[var(--color-border)]">
|
||||
{/*
|
||||
<h2 className="text-lg font-bold mb-4">
|
||||
Firmwareupdate
|
||||
{typeof slot === "number" ? ` KÜ ${slot}` : ""} läuft ...
|
||||
</h2>
|
||||
*/}
|
||||
<h2 className="text-lg font-bold mb-4">
|
||||
Firmwareupdate läuft ...
|
||||
{typeof slot === "number" ? ` ` : ""}
|
||||
</h2>
|
||||
Bitte Fenster nicht schließen
|
||||
<h2></h2>
|
||||
<div className="w-full h-4 rounded-full bg-[var(--color-surface-alt)]/80 dark:bg-[var(--color-surface-alt)]/30 border border-[var(--color-border)] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-200 bg-[var(--color-accent)]"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm">{Math.round(progress)}% abgeschlossen</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressModal;
|
||||
517
components/main/system/DetailModal.tsx
Normal file
517
components/main/system/DetailModal.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
// /components/main/system/DetailModal.tsx
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState, useAppDispatch } from "@/redux/store";
|
||||
import { setFullScreen } from "@/redux/slices/kabelueberwachungChartSlice";
|
||||
import { resetDateRange } from "@/redux/slices/dateRangePickerSlice";
|
||||
|
||||
// Import Thunks
|
||||
import SystemChartActionBar from "@/components/main/system/SystemChartActionBar";
|
||||
import { getSystemspannung5VplusThunk } from "@/redux/thunks/getSystemspannung5VplusThunk";
|
||||
import { getSystemspannung15VplusThunk } from "@/redux/thunks/getSystemspannung15VplusThunk";
|
||||
import { getSystemspannung15VminusThunk } from "@/redux/thunks/getSystemspannung15VminusThunk";
|
||||
import { getSystemspannung98VminusThunk } from "@/redux/thunks/getSystemspannung98VminusThunk";
|
||||
import { getTemperaturAdWandlerThunk } from "@/redux/thunks/getTemperaturAdWandlerThunk";
|
||||
import { getTemperaturProzessorThunk } from "@/redux/thunks/getTemperaturProzessorThunk";
|
||||
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
TimeScale,
|
||||
type ChartDataset,
|
||||
type ChartOptions,
|
||||
type ChartData,
|
||||
type Chart,
|
||||
} from "chart.js";
|
||||
|
||||
import "chartjs-adapter-date-fns";
|
||||
import { de } from "date-fns/locale";
|
||||
|
||||
ChartJS.register(
|
||||
LineElement,
|
||||
PointElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
TimeScale
|
||||
);
|
||||
|
||||
// Tailwind-basierte Farbdefinitionen für Chart.js
|
||||
const chartColors = {
|
||||
gray: {
|
||||
line: "#6B7280", // tailwind gray-500
|
||||
background: "rgba(107, 114, 128, 0.2)", // tailwind gray-500 mit opacity
|
||||
},
|
||||
littwinBlue: {
|
||||
line: "#00AEEF", // littwin-blue
|
||||
background: "rgba(0, 174, 239, 0.2)", // littwin-blue mit opacity
|
||||
},
|
||||
};
|
||||
|
||||
type ReduxDataEntry = {
|
||||
//Alle DIA0 t,m,i,a , DIA1 und DIA2 t,i,a,g
|
||||
t: string; // Zeitstempel
|
||||
i: number; // Minimum
|
||||
a: number; // Maximum
|
||||
g?: number; // Durchschnitt (optional, falls vorhanden)
|
||||
m?: number; // aktueller Messwert (optional, falls vorhanden)
|
||||
};
|
||||
|
||||
const chartOptions: ChartOptions<"line"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: "top" as const },
|
||||
title: {
|
||||
display: true,
|
||||
text: "Verlauf",
|
||||
},
|
||||
tooltip: {
|
||||
mode: "index" as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
label: function (ctx: any) {
|
||||
return `Messwert: ${ctx.parsed.y}`;
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
title: function (items: any[]) {
|
||||
const date = items[0].parsed.x;
|
||||
return `Zeitpunkt: ${new Date(date).toLocaleString("de-DE")}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
zoom: {
|
||||
pan: { enabled: true, mode: "x" as const },
|
||||
zoom: {
|
||||
wheel: { enabled: true },
|
||||
pinch: { enabled: true },
|
||||
mode: "x" as const,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: "time" as const,
|
||||
time: {
|
||||
unit: "day" as const,
|
||||
tooltipFormat: "dd.MM.yyyy HH:mm",
|
||||
displayFormats: {
|
||||
day: "dd.MM.yyyy",
|
||||
},
|
||||
},
|
||||
adapters: {
|
||||
date: { locale: de },
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: "Zeit",
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "Messwert",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
selectedKey: string | null;
|
||||
onClose: () => void;
|
||||
zeitraum: "DIA0" | "DIA1" | "DIA2";
|
||||
setZeitraum: (typ: "DIA0" | "DIA1" | "DIA2") => void;
|
||||
};
|
||||
|
||||
export const DetailModal = ({
|
||||
isOpen,
|
||||
selectedKey,
|
||||
onClose,
|
||||
zeitraum,
|
||||
setZeitraum,
|
||||
}: Props) => {
|
||||
// Stable empty reference to avoid React-Redux dev warning about selector returning new [] each call
|
||||
const EMPTY_REDUX_DATA: ReadonlyArray<ReduxDataEntry> = Object.freeze([]);
|
||||
const chartRef = useRef<Chart<"line"> | null>(null);
|
||||
const [chartData, setChartData] = useState<ChartData<"line">>({
|
||||
datasets: [],
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [shouldUpdateChart, setShouldUpdateChart] = useState(false);
|
||||
// const [forceUpdate, setForceUpdate] = useState(0); // Für periodische UI-Updates (derzeit nicht benötigt)
|
||||
|
||||
const reduxData = useSelector((state: RootState) => {
|
||||
switch (selectedKey) {
|
||||
case "+5V":
|
||||
return state.systemspannung5Vplus[zeitraum];
|
||||
case "+15V":
|
||||
return state.systemspannung15Vplus[zeitraum];
|
||||
case "-15V":
|
||||
return state.systemspannung15Vminus[zeitraum];
|
||||
case "-96V":
|
||||
return state.systemspannung98Vminus[zeitraum];
|
||||
case "ADC Temp":
|
||||
return state.temperaturAdWandler[zeitraum];
|
||||
case "CPU Temp":
|
||||
return state.temperaturProzessor[zeitraum];
|
||||
default:
|
||||
return EMPTY_REDUX_DATA;
|
||||
}
|
||||
}) as ReduxDataEntry[];
|
||||
|
||||
const isFullScreen = useSelector(
|
||||
(state: RootState) => state.kabelueberwachungChartSlice.isFullScreen
|
||||
);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// API-Request beim Klick auf "Daten laden" - memoized für useEffect dependency
|
||||
const handleFetchData = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
|
||||
// Clear previous chart data
|
||||
setChartData({ datasets: [] });
|
||||
|
||||
// Flag setzen, dass Chart nach Datenempfang aktualisiert werden soll
|
||||
setShouldUpdateChart(true);
|
||||
|
||||
switch (selectedKey) {
|
||||
case "+5V":
|
||||
dispatch(getSystemspannung5VplusThunk(zeitraum));
|
||||
break;
|
||||
case "+15V":
|
||||
dispatch(getSystemspannung15VplusThunk(zeitraum));
|
||||
break;
|
||||
case "-15V":
|
||||
dispatch(getSystemspannung15VminusThunk(zeitraum));
|
||||
break;
|
||||
case "-96V":
|
||||
dispatch(getSystemspannung98VminusThunk(zeitraum));
|
||||
break;
|
||||
case "ADC Temp":
|
||||
dispatch(getTemperaturAdWandlerThunk(zeitraum));
|
||||
break;
|
||||
case "CPU Temp":
|
||||
dispatch(getTemperaturProzessorThunk(zeitraum));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, [selectedKey, zeitraum, dispatch]);
|
||||
|
||||
// Reset Zeitraum auf DIA0 und Datumswerte wenn Modal geöffnet wird
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setZeitraum("DIA0");
|
||||
// Reset DateRangePicker to its defaults (it sets 30 days → today on mount)
|
||||
dispatch(resetDateRange());
|
||||
|
||||
// Chart-Daten zurücksetzen beim Öffnen
|
||||
setChartData({ datasets: [] });
|
||||
}
|
||||
}, [isOpen, setZeitraum, dispatch]);
|
||||
|
||||
// Periodische UI-Updates alle 2 Sekunden während Wartezeit
|
||||
useEffect(() => {
|
||||
if (isOpen && (!chartData.datasets || chartData.datasets.length === 0)) {
|
||||
// Optional: periodische Re-Renders wurden deaktiviert, da nicht mehr notwendig
|
||||
// (kann wieder aktiviert werden falls Cursor-Animation erwünscht ist)
|
||||
}
|
||||
}, [isOpen, chartData.datasets]);
|
||||
|
||||
// Automatisches "Daten laden" alle 4 Sekunden, maximal 2 Versuche
|
||||
useEffect(() => {
|
||||
if (isOpen && (!chartData.datasets || chartData.datasets.length === 0)) {
|
||||
let attempts = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (attempts < 2) {
|
||||
console.log("Auto-clicking 'Daten laden' button...");
|
||||
handleFetchData();
|
||||
attempts++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isOpen, chartData.datasets, handleFetchData]);
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
dispatch(setFullScreen(!isFullScreen));
|
||||
setTimeout(() => {
|
||||
chartRef.current?.resize();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(setFullScreen(false));
|
||||
dispatch(resetDateRange());
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadZoomPlugin = async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const zoomPlugin = (await import("chartjs-plugin-zoom")).default;
|
||||
if (!ChartJS.registry.plugins.get("zoom")) {
|
||||
ChartJS.register(zoomPlugin);
|
||||
}
|
||||
}
|
||||
};
|
||||
loadZoomPlugin();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current && selectedKey) {
|
||||
const opts = chartRef.current.options as ChartOptions<"line"> & {
|
||||
plugins?: { title?: { text?: string } };
|
||||
};
|
||||
if (opts.plugins?.title) {
|
||||
opts.plugins.title.text = `Verlauf ${selectedKey}`;
|
||||
}
|
||||
chartRef.current.update("none");
|
||||
}
|
||||
}, [selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartRef.current) {
|
||||
chartRef.current.resetZoom();
|
||||
}
|
||||
}, [zeitraum]);
|
||||
|
||||
// Chart.js animation complete callback to set isLoading false
|
||||
useEffect(() => {
|
||||
if (chartRef.current && isLoading) {
|
||||
const chartInstance = chartRef.current;
|
||||
// Save previous callback to restore later
|
||||
const animation: any = chartInstance.options.animation || {}; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
const prevCallback = animation.onComplete;
|
||||
animation.onComplete = () => {
|
||||
setIsLoading(false);
|
||||
if (typeof prevCallback === "function") prevCallback();
|
||||
};
|
||||
chartInstance.options.animation = animation;
|
||||
chartInstance.update();
|
||||
}
|
||||
}, [chartData, isLoading]);
|
||||
|
||||
// DateRange from global DateRangePicker slice
|
||||
const pickerVonDatum = useSelector(
|
||||
(state: RootState) => state.dateRangePicker.vonDatum
|
||||
);
|
||||
const pickerBisDatum = useSelector(
|
||||
(state: RootState) => state.dateRangePicker.bisDatum
|
||||
);
|
||||
|
||||
// Update chart data when Redux data changes (only after button click)
|
||||
useEffect(() => {
|
||||
if (shouldUpdateChart && reduxData && reduxData.length > 0) {
|
||||
// Filter data by selected date range (inclusive end date)
|
||||
let filtered = reduxData;
|
||||
try {
|
||||
if (pickerVonDatum && pickerBisDatum) {
|
||||
const start = new Date(`${pickerVonDatum}T00:00:00`);
|
||||
const end = new Date(`${pickerBisDatum}T23:59:59`);
|
||||
const s = start.getTime();
|
||||
const e = end.getTime();
|
||||
filtered = reduxData.filter((entry) => {
|
||||
const t = new Date(entry.t).getTime();
|
||||
return t >= s && t <= e;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Zeitfilter konnte nicht angewendet werden:", err);
|
||||
}
|
||||
|
||||
console.log("Redux data for chart (filtered):", filtered.length);
|
||||
if (!filtered.length) {
|
||||
setChartData({ datasets: [] });
|
||||
setShouldUpdateChart(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create datasets array for multiple lines
|
||||
const datasets: ChartDataset<"line">[] = [];
|
||||
|
||||
// Check which data fields are available and create datasets accordingly
|
||||
const hasMinimum = filtered.some(
|
||||
(entry) => entry.i !== undefined && entry.i !== null && entry.i !== 0
|
||||
);
|
||||
const hasMaximum = filtered.some(
|
||||
(entry) => entry.a !== undefined && entry.a !== null
|
||||
);
|
||||
const hasAverage = filtered.some(
|
||||
(entry) => entry.g !== undefined && entry.g !== null
|
||||
);
|
||||
const hasCurrent = filtered.some(
|
||||
(entry) => entry.m !== undefined && entry.m !== null
|
||||
);
|
||||
|
||||
// Zuerst Hintergrund-Linien (Minimum/Maximum) - grau
|
||||
if (hasMinimum) {
|
||||
datasets.push({
|
||||
label: "Messwert Minimum",
|
||||
data: filtered.map((entry) => ({
|
||||
x: new Date(entry.t).getTime(),
|
||||
y: entry.i || 0,
|
||||
})),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMaximum) {
|
||||
datasets.push({
|
||||
label: "Messwert Maximum",
|
||||
data: filtered.map((entry) => ({
|
||||
x: new Date(entry.t).getTime(),
|
||||
y: entry.a || 0,
|
||||
})),
|
||||
borderColor: "gray",
|
||||
borderWidth: 1,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
order: 3,
|
||||
});
|
||||
}
|
||||
|
||||
// Dann Vordergrund-Linien (Durchschnitt/Messwert) - littwin-blue
|
||||
if (hasAverage) {
|
||||
datasets.push({
|
||||
label: "Durchschnitt",
|
||||
data: filtered.map((entry) => ({
|
||||
x: new Date(entry.t).getTime(),
|
||||
y: entry.g || 0,
|
||||
})),
|
||||
borderColor: chartColors.littwinBlue.line,
|
||||
backgroundColor: chartColors.littwinBlue.background,
|
||||
tension: 0.1,
|
||||
fill: false,
|
||||
order: 2,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasCurrent) {
|
||||
datasets.push({
|
||||
label: "Messwert",
|
||||
data: filtered.map((entry) => ({
|
||||
x: new Date(entry.t).getTime(),
|
||||
y: entry.m || 0,
|
||||
})),
|
||||
borderColor: chartColors.littwinBlue.line,
|
||||
backgroundColor: chartColors.littwinBlue.background,
|
||||
tension: 0.1,
|
||||
fill: false,
|
||||
order: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const newChartData: ChartData<"line"> = {
|
||||
labels: [],
|
||||
datasets: datasets,
|
||||
};
|
||||
|
||||
console.log("Chart datasets:", datasets.length, "lines");
|
||||
setChartData(newChartData);
|
||||
setShouldUpdateChart(false); // Reset flag
|
||||
} else if (shouldUpdateChart && (!reduxData || reduxData.length === 0)) {
|
||||
console.log("No Redux data available");
|
||||
setChartData({ datasets: [] });
|
||||
setShouldUpdateChart(false); // Reset flag
|
||||
}
|
||||
}, [
|
||||
reduxData,
|
||||
selectedKey,
|
||||
shouldUpdateChart,
|
||||
pickerVonDatum,
|
||||
pickerBisDatum,
|
||||
]);
|
||||
|
||||
if (!isOpen || !selectedKey) return null;
|
||||
|
||||
// Prüfen ob Chart Daten haben (für cursor-wait)
|
||||
const hasChartData = chartData.datasets && chartData.datasets.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50 ${
|
||||
!hasChartData ? "cursor-wait" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={`bg-[var(--color-surface)] text-fg border border-base rounded-xl shadow-xl flex flex-col overflow-hidden transition-all duration-300 ${
|
||||
isFullScreen
|
||||
? "w-[90vw] h-[90vh]"
|
||||
: "w-[70rem] max-w-[95vw] h-[40rem]"
|
||||
} ${!hasChartData ? "cursor-wait" : ""}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-6 py-4 border-b border-base select-none bg-[var(--color-surface)]">
|
||||
<h2 className="text-base font-bold tracking-wide">
|
||||
Detailansicht: {selectedKey}
|
||||
</h2>
|
||||
<div className="flex items-center gap-3 text-lg">
|
||||
<button
|
||||
onClick={toggleFullScreen}
|
||||
className="icon-btn text-[1.4rem] hover:text-fg transition"
|
||||
aria-label={isFullScreen ? "Vollbild verlassen" : "Vollbild"}
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
className={
|
||||
isFullScreen
|
||||
? "bi bi-fullscreen-exit"
|
||||
: "bi bi-arrows-fullscreen"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="icon-btn text-[1.4rem] transition"
|
||||
aria-label="Modal schließen"
|
||||
type="button"
|
||||
>
|
||||
<i className="bi bi-x-circle-fill" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-h-0 flex flex-col px-6 pt-4 pb-5 bg-[var(--color-surface)] overflow-hidden">
|
||||
<div className="mb-3">
|
||||
<SystemChartActionBar
|
||||
zeitraum={zeitraum}
|
||||
setZeitraum={setZeitraum}
|
||||
onFetchData={handleFetchData}
|
||||
isLoading={isLoading}
|
||||
className="mb-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 rounded-lg border border-base bg-[var(--color-surface-alt)] px-3 py-2 shadow-inner">
|
||||
<Line ref={chartRef} data={chartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
{/* Optional Footer (currently empty, reserved for future) */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
94
components/main/system/SystemChartActionBar.tsx
Normal file
94
components/main/system/SystemChartActionBar.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
// components/main/system/SystemChartActionBar.tsx
|
||||
import React from "react";
|
||||
import DateRangePicker from "@/components/common/DateRangePicker";
|
||||
import { Listbox } from "@headlessui/react";
|
||||
|
||||
type Props = {
|
||||
zeitraum: "DIA0" | "DIA1" | "DIA2";
|
||||
setZeitraum: (typ: "DIA0" | "DIA1" | "DIA2") => void;
|
||||
onFetchData: () => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const SystemChartActionBar: React.FC<Props> = ({
|
||||
zeitraum,
|
||||
setZeitraum,
|
||||
onFetchData,
|
||||
isLoading = false,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-start gap-3 mb-4 flex-wrap ${className}`}
|
||||
>
|
||||
{/* DateRangePicker – nutzt globalen Redux-Slice */}
|
||||
<DateRangePicker compact />
|
||||
|
||||
{/* Zeitraum (DIA0/DIA1/DIA2) */}
|
||||
<label className="font-medium text-sm">Zeitraum:</label>
|
||||
<Listbox value={zeitraum} onChange={setZeitraum}>
|
||||
<div className="relative w-48">
|
||||
<Listbox.Button className="w-full border border-base px-3 py-1 rounded text-left bg-[var(--color-surface-alt)] text-fg flex justify-between items-center text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-accent)]/40 transition">
|
||||
<span>
|
||||
{
|
||||
{ DIA0: "Alle Messwerte", DIA1: "Stündlich", DIA2: "Täglich" }[
|
||||
zeitraum
|
||||
]
|
||||
}
|
||||
</span>
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--color-fg-muted)]"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M5.23 7.21a.75.75 0 011.06.02L10 10.585l3.71-3.355a.75.75 0 111.02 1.1l-4.25 3.85a.75.75 0 01-1.02 0l-4.25-3.85a.75.75 0 01.02-1.06z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</Listbox.Button>
|
||||
<Listbox.Options className="absolute z-50 mt-1 w-full border border-base rounded bg-[var(--color-surface)] text-fg shadow-lg max-h-60 overflow-auto text-sm focus:outline-none">
|
||||
{["DIA0", "DIA1", "DIA2"].map((option) => (
|
||||
<Listbox.Option
|
||||
key={option}
|
||||
value={option}
|
||||
className={({ selected, active }) => {
|
||||
const base = "px-4 py-1 cursor-pointer text-sm";
|
||||
if (selected) return `${base} bg-littwin-blue text-white`; // selected highlight
|
||||
if (active)
|
||||
return `${base} bg-[var(--color-surface-alt)] text-fg`;
|
||||
return `${base} text-fg`;
|
||||
}}
|
||||
>
|
||||
{
|
||||
{
|
||||
DIA0: "Alle Messwerte",
|
||||
DIA1: "Stündlich",
|
||||
DIA2: "Täglich",
|
||||
}[option as "DIA0" | "DIA1" | "DIA2"]
|
||||
}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</div>
|
||||
</Listbox>
|
||||
|
||||
{/* Daten laden */}
|
||||
<button
|
||||
onClick={onFetchData}
|
||||
className={`px-4 py-1 bg-littwin-blue text-white rounded text-sm ${
|
||||
isLoading ? "cursor-wait opacity-70" : ""
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{isLoading ? "Laden..." : "Daten laden"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemChartActionBar;
|
||||
185
components/main/system/SystemCharts.tsx
Normal file
185
components/main/system/SystemCharts.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
// components/main/system/SystemCharts.tsx
|
||||
|
||||
import React from "react";
|
||||
import { Line } from "react-chartjs-2";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
);
|
||||
|
||||
export type HistoryEntry = {
|
||||
time: string | number | Date;
|
||||
"+5V": number;
|
||||
"+15V": number;
|
||||
"-15V": number;
|
||||
"-96V": number;
|
||||
"ADC Temp": number;
|
||||
"CPU Temp": number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
history: HistoryEntry[];
|
||||
zeitraum: "DIA0" | "DIA1" | "DIA2";
|
||||
};
|
||||
export const SystemCharts = ({ history }: Props) => {
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const reversedHistory = [...history].reverse();
|
||||
const labels = reversedHistory.map((h) =>
|
||||
new Date(h.time).toLocaleTimeString()
|
||||
);
|
||||
|
||||
const formatValue = (v: number) => v.toFixed(2);
|
||||
|
||||
// Chart.js animation callback
|
||||
const animation = {
|
||||
onComplete: () => {
|
||||
setIsLoading(false);
|
||||
},
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoading(true);
|
||||
}, [history]);
|
||||
|
||||
const baseOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
grid: { color: "rgba(200,200,200,0.2)" },
|
||||
title: { display: true, text: "Wert" },
|
||||
},
|
||||
x: {
|
||||
grid: { color: "rgba(200,200,200,0.2)" },
|
||||
title: { display: true, text: "Zeit" },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { position: "bottom" as const },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid grid-cols-1 xl:grid-cols-2 gap-8 ${
|
||||
isLoading ? "cursor-wait" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="h-[300px]">
|
||||
<Line
|
||||
data={{
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "+5V",
|
||||
data: history.map((h) => formatValue(h["+5V"])),
|
||||
borderColor: "rgba(59,130,246,1)",
|
||||
backgroundColor: "rgba(59,130,246,0.5)",
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: "+15V",
|
||||
data: history.map((h) => formatValue(h["+15V"])),
|
||||
borderColor: "rgba(34,197,94,1)",
|
||||
backgroundColor: "rgba(34,197,94,0.5)",
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: "-15V",
|
||||
data: history.map((h) => formatValue(h["-15V"])),
|
||||
borderColor: "rgba(239,68,68,1)",
|
||||
backgroundColor: "rgba(239,68,68,0.5)",
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: "-96V",
|
||||
data: history.map((h) => formatValue(h["-96V"])),
|
||||
borderColor: "rgba(234,179,8,1)",
|
||||
backgroundColor: "rgba(234,179,8,0.5)",
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: "Spannung (V)", // 👉 Einheit hinzugefügt
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
title: { display: true, text: "Systemspannungen" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-[300px]">
|
||||
<Line
|
||||
data={{
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "ADC Temp",
|
||||
data: history.map((h) => h["ADC Temp"]),
|
||||
borderColor: "rgba(168,85,247,1)",
|
||||
backgroundColor: "rgba(168,85,247,0.5)",
|
||||
fill: false,
|
||||
},
|
||||
{
|
||||
label: "CPU Temp",
|
||||
data: history.map((h) =>
|
||||
parseFloat(formatValue(h["CPU Temp"]))
|
||||
),
|
||||
borderColor: "rgba(251,191,36,1)",
|
||||
backgroundColor: "rgba(251,191,36,0.5)",
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
options={{
|
||||
...baseOptions,
|
||||
scales: {
|
||||
...baseOptions.scales,
|
||||
y: {
|
||||
...baseOptions.scales.y,
|
||||
title: {
|
||||
display: true,
|
||||
text: "Temperatur (°C)", // 👉 Einheit hinzugefügt
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
...baseOptions.plugins,
|
||||
title: { display: true, text: "Systemtemperaturen" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
components/main/system/SystemOverviewGrid.tsx
Normal file
36
components/main/system/SystemOverviewGrid.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
// components/main/system/SystemOverviewGrid.tsx
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
voltages: Record<string, number>;
|
||||
onOpenDetail: (key: string) => void;
|
||||
};
|
||||
|
||||
export const SystemOverviewGrid = ({ voltages, onOpenDetail }: Props) => {
|
||||
const formatValue = (value: number) => value.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 mb-2">
|
||||
{Object.entries(voltages).map(([key, value]) => {
|
||||
const unit = key.includes("Temp") ? "\u00b0C" : "V";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="p-4 border rounded shadow-sm bg-[var(--color-surface)] dark:bg-[var(--color-surface)] border-[var(--color-border)] text-[var(--color-fg)] hover:bg-[var(--color-surface-alt)]/60 dark:hover:bg-[var(--color-surface-alt)]/30 transition"
|
||||
>
|
||||
<h2 className="font-semibold">{key}</h2>
|
||||
<p>
|
||||
{formatValue(value)} {unit}
|
||||
<button
|
||||
onClick={() => onOpenDetail(key)}
|
||||
className="ml-2 text-littwin-blue hover:underline text-sm"
|
||||
>
|
||||
Detailansicht
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
components/main/system/SystemView.tsx
Normal file
109
components/main/system/SystemView.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// components/main/system/system.tsx
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { AppDispatch, RootState } from "@/redux/store";
|
||||
import { getSystemVoltTempThunk } from "@/redux/thunks/getSystemVoltTempThunk";
|
||||
import { SystemOverviewGrid } from "@/components/main/system/SystemOverviewGrid";
|
||||
import { SystemCharts } from "@/components/main/system/SystemCharts";
|
||||
import { DetailModal } from "@/components/main/system/DetailModal";
|
||||
import type { HistoryEntry } from "@/components/main/system/SystemCharts";
|
||||
import { getSystemspannung5VplusThunk } from "@/redux/thunks/getSystemspannung5VplusThunk";
|
||||
import { getSystemspannung15VplusThunk } from "@/redux/thunks/getSystemspannung15VplusThunk";
|
||||
import { getSystemspannung15VminusThunk } from "@/redux/thunks/getSystemspannung15VminusThunk";
|
||||
import { getSystemspannung98VminusThunk } from "@/redux/thunks/getSystemspannung98VminusThunk";
|
||||
import { getTemperaturAdWandlerThunk } from "@/redux/thunks/getTemperaturAdWandlerThunk";
|
||||
import { getTemperaturProzessorThunk } from "@/redux/thunks/getTemperaturProzessorThunk";
|
||||
import { ClipLoader } from "react-spinners";
|
||||
|
||||
const SystemPage = () => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const voltages = useSelector(
|
||||
(state: RootState) => state.systemVoltTemp.voltages
|
||||
);
|
||||
|
||||
const history = useSelector(
|
||||
(state: RootState) => state.systemVoltTemp.history
|
||||
) as HistoryEntry[];
|
||||
const isLoading = !history.length || Object.keys(voltages).length === 0;
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [zeitraum, setZeitraum] = useState<"DIA0" | "DIA1" | "DIA2">("DIA1");
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getSystemVoltTempThunk());
|
||||
|
||||
const interval = setInterval(() => {
|
||||
dispatch(getSystemVoltTempThunk());
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleOpenDetail = (key: string) => {
|
||||
setSelectedKey(key);
|
||||
setIsModalOpen(true);
|
||||
switch (key) {
|
||||
case "+5V":
|
||||
dispatch(getSystemspannung5VplusThunk(zeitraum));
|
||||
break;
|
||||
case "+15V":
|
||||
dispatch(getSystemspannung15VplusThunk(zeitraum));
|
||||
break;
|
||||
case "-15V":
|
||||
dispatch(getSystemspannung15VminusThunk(zeitraum));
|
||||
break;
|
||||
case "-96V":
|
||||
dispatch(getSystemspannung98VminusThunk(zeitraum));
|
||||
break;
|
||||
case "ADC Temp":
|
||||
dispatch(getTemperaturAdWandlerThunk(zeitraum));
|
||||
break;
|
||||
case "CPU Temp":
|
||||
dispatch(getTemperaturProzessorThunk(zeitraum));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
const handleCloseDetail = () => {
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-[var(--color-background)] text-[var(--color-fg)]">
|
||||
<h1 className="text-xl font-bold mb-4 tracking-wide">
|
||||
System Spannungen & Temperaturen
|
||||
</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-[400px]">
|
||||
<div className="text-center">
|
||||
<ClipLoader size={50} color="#3B82F6" />
|
||||
<p className="mt-4 text-[var(--color-fg-muted)]">
|
||||
Lade Systemdaten … bitte warten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SystemOverviewGrid
|
||||
voltages={voltages}
|
||||
onOpenDetail={handleOpenDetail}
|
||||
/>
|
||||
<SystemCharts history={history} zeitraum={zeitraum} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DetailModal
|
||||
isOpen={isModalOpen}
|
||||
selectedKey={selectedKey}
|
||||
onClose={handleCloseDetail}
|
||||
zeitraum={zeitraum}
|
||||
setZeitraum={setZeitraum}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemPage;
|
||||
@@ -26,7 +26,7 @@ const Navigation: React.FC<NavigationProps> = ({ className }) => {
|
||||
{ name: "Kabelüberwachung ", path: "/kabelueberwachung" },
|
||||
{ name: "Meldungseingänge ", path: "/digitalInputs" }, //vorher Digitale Ein -und Ausgänge
|
||||
{ name: "Schaltausgänge ", path: "/digitalOutputs", disabled: false }, //vorher Digitale Ein -und Ausgänge
|
||||
{ name: "Messwerteingänge ", path: "/analogeEingaenge" }, //vorher Analoge Eingänge
|
||||
{ name: "Messwerteingänge ", path: "/analogInputs" }, //vorher Analoge Eingänge
|
||||
{ name: "Berichte ", path: "/meldungen" },
|
||||
{ name: "System ", path: "/system" },
|
||||
{ name: "Einstellungen ", path: "/einstellungen" },
|
||||
@@ -36,25 +36,28 @@ const Navigation: React.FC<NavigationProps> = ({ className }) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<aside>
|
||||
<nav className={`h-full flex-shrink-0 mt-16 ${className || "w-48"}`}>
|
||||
<aside className="h-full bg-[var(--color-surface)] dark:bg-[var(--color-surface)] ">
|
||||
<nav className={`h-full flex-shrink-0 mt-24 ${className || "w-48"}`}>
|
||||
{menuItems.map((item) => (
|
||||
<div key={item.name}>
|
||||
{item.disabled ? (
|
||||
<div className="block px-4 py-2 mb-4 font-bold whitespace-nowrap text-gray-400 cursor-not-allowed text-[1rem] sm:text-[1rem] md:text-[1rem] lg:text-[1rem] xl:text-sm 2xl:text-lg">
|
||||
<div className="block px-4 py-2 mb-4 font-bold whitespace-nowrap text-[var(--color-fg-muted)] opacity-60 cursor-not-allowed text-[1rem] sm:text-[1rem] md:text-[1rem] lg:text-[1rem] xl:text-sm 2xl:text-lg">
|
||||
{item.name}
|
||||
</div>
|
||||
) : (
|
||||
<Link href={formatPath(item.path)}>
|
||||
<div
|
||||
className={`block px-4 py-2 mb-4 font-bold whitespace-nowrap transition duration-300 text-[1rem] sm:text-[1rem] md:text-[1rem] lg:text-[1rem] xl:text-sm 2xl:text-lg ${
|
||||
activeLink.startsWith(item.path)
|
||||
? "bg-sky-500 text-white rounded-r-full xl:mr-4 xl:w-full"
|
||||
: "text-black hover:bg-gray-200 rounded-r-full"
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
<Link
|
||||
href={formatPath(item.path)}
|
||||
prefetch={false}
|
||||
onClick={() => setActiveLink(item.path)}
|
||||
className={`block px-4 py-2 mb-4 font-semibold whitespace-nowrap transition duration-200 rounded-r-full pr-6 relative text-[1rem] sm:text-[1rem] md:text-[1rem] lg:text-[1rem] xl:text-sm 2xl:text-lg
|
||||
${
|
||||
activeLink.startsWith(item.path)
|
||||
? "bg-[var(--color-accent)] text-white shadow-sm xl:mr-4 xl:w-full"
|
||||
: "text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:bg-[var(--color-surface-alt)]/80 dark:hover:bg-[var(--color-surface-alt)]/40"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
80
create_presentation.py
Normal file
80
create_presentation.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from pptx import Presentation
|
||||
from pptx.util import Inches, Pt
|
||||
from pptx.enum.text import PP_ALIGN
|
||||
|
||||
prs = Presentation()
|
||||
|
||||
def add_slide(title, content_lines):
|
||||
slide_layout = prs.slide_layouts[1] # Title and Content
|
||||
slide = prs.slides.add_slide(slide_layout)
|
||||
title_placeholder = slide.shapes.title
|
||||
content_placeholder = slide.placeholders[1]
|
||||
title_placeholder.text = title
|
||||
tf = content_placeholder.text_frame
|
||||
tf.clear()
|
||||
for line in content_lines:
|
||||
p = tf.add_paragraph()
|
||||
p.text = line
|
||||
p.font.size = Pt(20)
|
||||
p.alignment = PP_ALIGN.LEFT
|
||||
|
||||
# Folie 1: Titel
|
||||
add_slide("Testing CPL V4 Webseiten", ["Von: Ismail Ali", "Datum: 22.08.2025"])
|
||||
|
||||
# Folie 2 entfernt
|
||||
|
||||
# Folie 3: Warum testen wir?
|
||||
add_slide("Warum testen wir?", [
|
||||
"Um sicherzustellen, dass die Weboberfläche richtig funktioniert.",
|
||||
"Fehler frühzeitig erkennen und beheben.",
|
||||
"Qualität und Zuverlässigkeit verbessern."
|
||||
])
|
||||
|
||||
# Folie 4: Was ist Playwright?
|
||||
add_slide("Was ist Playwright?", [
|
||||
"Ein Open-Source-Testframework von Microsoft.",
|
||||
"Ermöglicht automatisierte Tests in verschiedenen Browsern (Chromium, Firefox, WebKit):",
|
||||
"🟦 Chromium 🦊 Firefox 🍏 WebKit",
|
||||
"Simuliert echte Benutzeraktionen wie Klicks, Eingaben und Navigation.",
|
||||
"Unterstützt mehrere Programmiersprachen: JavaScript, TypeScript, Python, Java, .NET (C#).",
|
||||
"Ideal für End-to-End-Tests von Webanwendungen."
|
||||
])
|
||||
|
||||
# Folie 5: Wie habe ich getestet?
|
||||
add_slide("Wie habe ich getestet?", [
|
||||
"Mit Playwright automatisierte Tests geschrieben.",
|
||||
"Playwright Recorder (codegen) verwendet, da es einfacher ist als manuellen Code zu schreiben.",
|
||||
"Verschiedene Seiten des CPL V4 Webservers getestet:",
|
||||
"- Dashboard",
|
||||
"- Analoge Eingänge",
|
||||
"- Einstellungen"
|
||||
])
|
||||
|
||||
# Folie 6: Beispiel-Test (Ausschnitt)
|
||||
add_slide("Beispiel-Test (Ausschnitt)", [
|
||||
"Test prüft, ob wichtige Elemente sichtbar sind.",
|
||||
"Beispiel: Überschrift, Buttons, Tabellenzellen.",
|
||||
"Klicks und Eingaben werden simuliert."
|
||||
])
|
||||
|
||||
# Folie 7: Test-Ergebnisse
|
||||
add_slide("Test-Ergebnisse", [
|
||||
"Alle Tests wurden erfolgreich ausgeführt.",
|
||||
"Keine Fehler gefunden (siehe Test-Report)."
|
||||
])
|
||||
|
||||
# Folie 8: Fazit
|
||||
add_slide("Fazit", [
|
||||
"Automatisierte Tests helfen, Fehler schnell zu finden.",
|
||||
"Playwright ist einfach zu bedienen, auch für Anfänger.",
|
||||
"Tests machen die Entwicklung sicherer und effizienter."
|
||||
])
|
||||
|
||||
# Folie 9: Fragen?
|
||||
add_slide("Fragen?", [
|
||||
"Vielen Dank für die Aufmerksamkeit!",
|
||||
"Gibt es Fragen?"
|
||||
])
|
||||
|
||||
prs.save("Testing_CPLV4_Webserver.pptx")
|
||||
print("Präsentation erstellt: Testing_CPLV4_Webserver.pptx")
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig } from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
setupNodeEvents(on, config) {
|
||||
// implement node event listeners here
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
describe('Kue705FO Integration Tests', () => {
|
||||
beforeEach(() => {
|
||||
// Besuche die Seite, auf der die Komponente gerendert wird
|
||||
//cy.visit('/path-to-your-component'); // Passe den Pfad an deine App an
|
||||
cy.visit('http://localhost:3000/kabelueberwachung');
|
||||
});
|
||||
|
||||
it('should render the component with default props', () => {
|
||||
// Überprüfe, ob der Modulname und die Slotnummer angezeigt werden
|
||||
cy.contains('KÜ705-FO').should('be.visible');
|
||||
cy.contains('Modul 1').should('be.visible'); // Beispiel für den Modulnamen
|
||||
});
|
||||
|
||||
it('should update display when TDR button is clicked', () => {
|
||||
// Klicke auf den TDR-Button
|
||||
cy.contains('TDR').click();
|
||||
|
||||
// Überprüfe, ob der Text aktualisiert wurde
|
||||
cy.contains('Entfernung [Km]').should('be.visible');
|
||||
});
|
||||
|
||||
it('should switch back to Schleife display', () => {
|
||||
// Klicke auf TDR, dann zurück zu Schleife
|
||||
cy.contains('TDR').click();
|
||||
cy.contains('Schleife').click();
|
||||
|
||||
// Überprüfe, ob der Text aktualisiert wurde
|
||||
cy.contains('Schleifenwiderstand [kOhm]').should('be.visible');
|
||||
});
|
||||
|
||||
it('should disable TDR button when tdrActive is 0', () => {
|
||||
// Dies erfordert eine benutzerdefinierte Backend-Konfiguration oder Redux-Manipulation
|
||||
cy.contains('TDR').should('be.disabled');
|
||||
});
|
||||
|
||||
it('should open and close the settings modal', () => {
|
||||
// Öffne das Modal
|
||||
cy.contains('⚙').click();
|
||||
cy.contains('KUE Einstellung - Slot 1').should('be.visible');
|
||||
|
||||
// Schließe das Modal
|
||||
cy.contains('×').click();
|
||||
cy.contains('KUE Einstellung - Slot 1').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 122 KiB |
@@ -1,37 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -1,17 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/e2e.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
142
docs/KVZ/kvz-system-understanding.md
Normal file
142
docs/KVZ/kvz-system-understanding.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# KVZ System - Mein aktuelles Verständnis
|
||||
|
||||
## System Übersicht
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Kabelüberwachung System (32 Slots)"
|
||||
Slot0["Slot 0<br/>Kabelüberwachung"]
|
||||
Slot1["Slot 1<br/>Kabelüberwachung"]
|
||||
Slot2["Slot 2<br/>Kabelüberwachung"]
|
||||
Slot3["Slot 3<br/>Kabelüberwachung"]
|
||||
SlotDots["..."]
|
||||
Slot31["Slot 31<br/>Kabelüberwachung"]
|
||||
end
|
||||
|
||||
subgraph "KVZ Geräte (Optional pro Slot)"
|
||||
KVZ0["KVZ Gerät<br/>für Slot 0"]
|
||||
KVZ1["KVZ Gerät<br/>für Slot 1"]
|
||||
KVZ2["KVZ Gerät<br/>für Slot 2"]
|
||||
KVZ3["KVZ Gerät<br/>für Slot 3"]
|
||||
end
|
||||
|
||||
subgraph "KVZ LEDs (4 pro KVZ Gerät)"
|
||||
subgraph "KVZ0 LEDs"
|
||||
LED0_0["LED 1"]
|
||||
LED0_1["LED 2"]
|
||||
LED0_2["LED 3"]
|
||||
LED0_3["LED 4"]
|
||||
end
|
||||
|
||||
subgraph "KVZ1 LEDs"
|
||||
LED1_0["LED 1"]
|
||||
LED1_1["LED 2"]
|
||||
LED1_2["LED 3"]
|
||||
LED1_3["LED 4"]
|
||||
end
|
||||
end
|
||||
|
||||
Slot0 -.-> KVZ0
|
||||
Slot1 -.-> KVZ1
|
||||
Slot2 -.-> KVZ2
|
||||
Slot3 -.-> KVZ3
|
||||
|
||||
KVZ0 --> LED0_0
|
||||
KVZ0 --> LED0_1
|
||||
KVZ0 --> LED0_2
|
||||
KVZ0 --> LED0_3
|
||||
|
||||
KVZ1 --> LED1_0
|
||||
KVZ1 --> LED1_1
|
||||
KVZ1 --> LED1_2
|
||||
KVZ1 --> LED1_3
|
||||
```
|
||||
|
||||
## Redux Data Structure - Mein Verständnis
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Redux Store"
|
||||
subgraph "kvzPresence Array (32 Elemente)"
|
||||
P0["Index 0: 1<br/>(KVZ vorhanden)"]
|
||||
P1["Index 1: 0<br/>(KVZ nicht vorhanden)"]
|
||||
P2["Index 2: 0"]
|
||||
P3["Index 3: 0"]
|
||||
PDots["..."]
|
||||
P31["Index 31: 0"]
|
||||
end
|
||||
|
||||
subgraph "kvzStatus Array (128 Elemente)"
|
||||
subgraph "Slot 0 LEDs (Index 0-3)"
|
||||
S0_0["Index 0: 1 (grün)"]
|
||||
S0_1["Index 1: 0 (rot)"]
|
||||
S0_2["Index 2: 1 (grün)"]
|
||||
S0_3["Index 3: 0 (rot)"]
|
||||
end
|
||||
|
||||
subgraph "Slot 1 LEDs (Index 4-7)"
|
||||
S1_0["Index 4: 0"]
|
||||
S1_1["Index 5: 0"]
|
||||
S1_2["Index 6: 0"]
|
||||
S1_3["Index 7: 0"]
|
||||
end
|
||||
|
||||
StatusDots["...weitere 120 Elemente"]
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
## UI Darstellung - Mein aktuelles Verständnis
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "FallSensors UI Component"
|
||||
subgraph "Aktuelle Implementierung (FALSCH?)"
|
||||
UI1["KVZ1: 🟢<br/>(kvzPresence[0] = 1)"]
|
||||
UI2["KVZ2: 🔴<br/>(kvzPresence[1] = 0)"]
|
||||
UI3["KVZ3: 🔴<br/>(kvzPresence[2] = 0)"]
|
||||
UI4["KVZ4: 🔴<br/>(kvzPresence[3] = 0)"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph "Problem"
|
||||
Problem["Alle KVZ zeigen den gleichen Status<br/>basierend auf kvzPresence Array<br/>→ NICHT korrekt!"]
|
||||
end
|
||||
|
||||
UI1 -.-> Problem
|
||||
UI2 -.-> Problem
|
||||
UI3 -.-> Problem
|
||||
UI4 -.-> Problem
|
||||
```
|
||||
|
||||
## Fragen zu meinem Verständnis
|
||||
|
||||
1. **KVZ Geräte Zuordnung**:
|
||||
|
||||
- Ist ein KVZ-Gerät einem Slot zugeordnet oder unabhängig?
|
||||
- Wie viele KVZ-Geräte gibt es insgesamt?
|
||||
|
||||
2. **UI KVZ1-KVZ4**:
|
||||
|
||||
- Repräsentieren KVZ1-KVZ4 in der UI die ersten 4 Slots (0-3)?
|
||||
- Oder sind es 4 separate, unabhängige KVZ-Geräte?
|
||||
|
||||
3. **LED Status Mapping**:
|
||||
|
||||
- Welche LED von welchem KVZ soll in KVZ1, KVZ2, KVZ3, KVZ4 angezeigt werden?
|
||||
- Soll jedes UI-KVZ eine andere LED des gleichen Geräts zeigen?
|
||||
- Oder soll jedes UI-KVZ ein anderes KVZ-Gerät repräsentieren?
|
||||
|
||||
4. **kvzStatus Array**:
|
||||
- Wie soll das 128-Element Array für die UI-Darstellung genutzt werden?
|
||||
- Welche Indizes entsprechen welchen UI-Elementen?
|
||||
|
||||
## Verdacht
|
||||
|
||||
Ich vermute, dass mein aktueller Ansatz falsch ist, weil:
|
||||
|
||||
- KVZ2 sollte nicht eine Kopie von KVZ1 Status sein
|
||||
- Jedes KVZ in der UI sollte einen eigenen, unabhängigen Status haben
|
||||
- Die Zuordnung zwischen Redux Arrays und UI ist unklar
|
||||
|
||||
**Bitte korrigieren Sie mein Verständnis! 🤔**
|
||||
141
docs/KVZ/mock-data.md
Normal file
141
docs/KVZ/mock-data.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# KVZ Mock Data - Dokumentation
|
||||
|
||||
## Mock-Daten Struktur
|
||||
|
||||
Die KVZ Mock-Daten befinden sich in `mocks/kvzData.json` und haben folgende Struktur:
|
||||
|
||||
### Beispiel-Daten
|
||||
|
||||
```json
|
||||
{
|
||||
"kvzPresence": [1, 0, 1, 0, ...], // 32 Elemente
|
||||
"kvzStatus": [1, 0, 1, 0, ...], // 128 Elemente
|
||||
"timestamp": "2025-01-31T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### kvzPresence Array (32 Elemente)
|
||||
|
||||
- **Index 0-31**: Repräsentiert Slots 0-31
|
||||
- **Wert 1**: KVZ-Gerät vorhanden
|
||||
- **Wert 0**: KVZ-Gerät nicht vorhanden
|
||||
|
||||
**Aktuelle Mock-Daten:**
|
||||
|
||||
- Slot 0: KVZ vorhanden (1)
|
||||
- Slot 1: KVZ nicht vorhanden (0)
|
||||
- Slot 2: KVZ vorhanden (1)
|
||||
- Slot 3-31: KVZ nicht vorhanden (0)
|
||||
|
||||
### kvzStatus Array (128 Elemente)
|
||||
|
||||
- **Index Berechnung**: `slotIndex * 4 + ledIndex`
|
||||
- **4 LEDs pro Slot**: LED 0, LED 1, LED 2, LED 3
|
||||
- **Wert 1**: LED aktiv (grün)
|
||||
- **Wert 0**: LED inaktiv (rot)
|
||||
|
||||
**Aktuelle Mock-Daten:**
|
||||
|
||||
- Slot 0 LEDs (Index 0-3): [1, 0, 1, 0] → LED1=grün, LED2=rot, LED3=grün, LED4=rot
|
||||
- Slot 1 LEDs (Index 4-7): [0, 0, 0, 0] → Alle LEDs rot (KVZ nicht vorhanden)
|
||||
- Slot 2 LEDs (Index 8-11): [1, 1, 0, 1] → LED1=grün, LED2=grün, LED3=rot, LED4=grün
|
||||
- Slot 3-31: Alle LEDs rot (0)
|
||||
|
||||
## API Endpunkte
|
||||
|
||||
### GET /api/kvz/data
|
||||
|
||||
Holt alle KVZ-Daten.
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"kvzPresence": [...],
|
||||
"kvzStatus": [...],
|
||||
"timestamp": "2025-01-31T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/kvz/data
|
||||
|
||||
Ersetzt komplette KVZ-Daten.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"kvzPresence": [...],
|
||||
"kvzStatus": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/kvz/updateSettings
|
||||
|
||||
Aktualisiert spezifische KVZ-Einstellungen.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": [
|
||||
{
|
||||
"key": "kvzPresence",
|
||||
"slot": 0,
|
||||
"value": 1
|
||||
},
|
||||
{
|
||||
"key": "kvzStatus",
|
||||
"slot": 0,
|
||||
"ledIndex": 1,
|
||||
"value": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Service Functions
|
||||
|
||||
### fetchKvzData()
|
||||
|
||||
```typescript
|
||||
import { fetchKvzData } from "../services/fetchKvzDataService";
|
||||
|
||||
const data = await fetchKvzData();
|
||||
console.log(data.kvzPresence); // [1, 0, 1, 0, ...]
|
||||
```
|
||||
|
||||
### updateKvzSettings()
|
||||
|
||||
```typescript
|
||||
import { updateKvzSettings } from "../services/fetchKvzDataService";
|
||||
|
||||
await updateKvzSettings([
|
||||
{ key: "kvzPresence", slot: 0, value: 1 },
|
||||
{ key: "kvzStatus", slot: 0, ledIndex: 1, value: 1 },
|
||||
]);
|
||||
```
|
||||
|
||||
## Redux Integration
|
||||
|
||||
Die Mock-Daten können in Redux geladen werden:
|
||||
|
||||
```typescript
|
||||
// In einem Thunk oder useEffect
|
||||
const data = await fetchKvzData();
|
||||
dispatch(
|
||||
setKueData({
|
||||
kvzPresence: data.kvzPresence,
|
||||
kvzStatus: data.kvzStatus,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Testen
|
||||
|
||||
Die Mock-Daten ermöglichen es:
|
||||
|
||||
1. **KVZ-Geräte zu simulieren** (Slots 0 und 2 haben KVZ)
|
||||
2. **LED-Status zu testen** (verschiedene Kombinationen)
|
||||
3. **API-Updates zu testen** (Presence und Status ändern)
|
||||
4. **UI-Verhalten zu validieren** (bedingte Anzeige, Farben)
|
||||
BIN
docs/Lastenheft/CPL V4 Lastenheft 03.09.2025.pdf
Normal file
BIN
docs/Lastenheft/CPL V4 Lastenheft 03.09.2025.pdf
Normal file
Binary file not shown.
102
docs/TODO.md
102
docs/TODO.md
@@ -18,3 +18,105 @@
|
||||
- [ ] TODO: Alle Kabelüberwachungsmodule mit ein Button Updaten , in Einstellungen und in Kabelüberwachungsmodul Modal
|
||||

|
||||
Zeit bis Ende August
|
||||
- [ ] TODO: Überall Littwin-Blau und ausgewählt und grau wie bei Navigation bei Mouse over
|
||||
- [ ] ## TODO: Messwerteingänge Diagrammme /Messkurven
|
||||
23.07.2025
|
||||
- [x] TODO: Isolationsfehler in Display anzeigen -> aktuell Zahl ist rot ohne Beschrifftung , es soll Zahl ISO MOhm und Isolationsfehler
|
||||
- [x] TODO: Kilometer Km -> km kleingeschrieben 1000, 1024 wird Großgeschrieben Kilobyte Kb
|
||||
- [x] TODO: Messwerteingänge Mouse couror wait beim laden, damit der user etwas wartet
|
||||
- [ ] ## TODO: In KÜ, unter KÜ Balken/Bereich für Scheleife, Bereich für TDR wenn aktiv ist und Bereich für KVz wenn aktive ist
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
24.07.2025
|
||||
|
||||
- [ ] TODO: Bei System Messkurven Cursor await bis die daten lädt
|
||||
- [ ] TODO: in KÜ DISPLAY Fehler, ISO Wert und Schleifenwwert bei wechsel Zustand anzeigen während Schleifenmessung und Isowemmsung und kalibirirung
|
||||
- [ ] TODO: Bug in DatePicker in KÜ
|
||||
- [ ] TODO: Kurven für ISO, RSL und TDR
|
||||
- [x] TODO: in KÜ Steckplatz in KÜ umbenennen
|
||||
- [x] TODO: Firmware Update Bestätigung in Littwin blau auch progress in littwinblue
|
||||
- [x] TODO: KÜ Firmware in progress Bitte Fenster nicht schließen
|
||||
- [ ] TODO: KÜ Kurve und letzte historische Meldungen anzeigen -> später
|
||||
- [ ] TODO: KVz später
|
||||
|
||||
Anzeige KÜ-Display:
|
||||
|
||||
1. Zeile Alarm: Isolationsfehler, Schleifenfehler, Aderbruch, Erdschluß, Messpannung: Immer in Rot; wenn kein Alarm, bleibt die Zeile leer
|
||||
|
||||
2. Zeile: Isowert: xx MOhm (großes M)
|
||||
|
||||
in Rot, wenn Iso-Fehler ansteht
|
||||
|
||||
Beispiel: ISO: 100 MOHm der beim Abliech: ISO: Abgleich
|
||||
|
||||
3. Zeile: Schleifenwert, xx kOhm (kleines k)
|
||||
|
||||
in Rot, wenn Schleifenfehler ansteht
|
||||
|
||||
## Beispiel:: RSL: 1,7 kOhm oder wenn Schleifenmessung aktiv: RSL: Messung
|
||||
|
||||
## 11.08.2025
|
||||
|
||||
- [x] TODO: Bei Schleife starten messen wie lange es dauert, dann entsprechend progress balken einbauen, 2 Minuten erstellt
|
||||
|
||||
# 13.08.2025
|
||||
|
||||
- [x] TODO: Das Sichern und das Zurücksichern der KÜ-Daten über die Webseiten funktioniert nicht. Anscheinend ruft die Webseite keine ACP-Webseite mit Daten "?KSB02=1" auf sondern nur Daten "KSB02=1". Die CPL will dann die Datei KSB02=1 laden die es ja nicht gibt.
|
||||
- [x] TODO: Kalibrieren Dauer entsprechend progress balken einbauen
|
||||
- [x] TODO: Abgleich Dauer entsprechend progress balken einbauen
|
||||
- [ ] TODO: Benutzer passwort ändern
|
||||
- [ ] TODO: PlayWright
|
||||
- ISO Abgleich 10 Minuten
|
||||
- CPL läuft auf BUS-System, Wenn ein Kabelüberwachung z.B. beschäftigt mit ein Schleifen-Messung oder Iso- Abgleich dann belastet den CPL nicht wenn andere KÜs bedient werden, das erleichtet den CPL sogar
|
||||
|
||||
# 14.08.2025
|
||||
|
||||
- [x] TODO: Messwerteingänge Messkurven in Modal umwandeln
|
||||
|
||||
# 15.08.2025
|
||||
|
||||
- [x] BUGFIX: Messkurven-Modal lädt jetzt automatisch die Kurve beim Öffnen, Dropdown ist auf 'Alle Messwerte' (DIA0) initialisiert, und Filter werden beim Schließen zurückgesetzt. Dateien: IsoChartView.tsx, LoopChartView.tsx
|
||||
|
||||
# 01.09.2025
|
||||
|
||||
- [x] TODO: In KÜs Display ISO 2 Nachkommastellen und RSL 3 Nachkommastellen
|
||||
- [ ] TODO: Schleife, Timer für jeder KÜ separate und nicht eine für alle, aktuell wird prozentzahl bei allen das gleiche angezeigt
|
||||
- [x] TODO: RSL starten in RSL Messung starten umbenennen
|
||||
- [x] TODO: TDR-Messung starten statt TDR aktivieren in ChartBar
|
||||
- [x] TODO: KÜ TDR-aktiviert alert entfernen
|
||||
- [x] TODO: Systemdaten unter Detailansicht ein Verlaufsdiagramm hinzufügen mit Datumsauswahl
|
||||
|
||||
# Kai Schmidt:
|
||||
|
||||
# Folgende Erweiterung / Neuerungen:
|
||||
|
||||
[x] TODO: Messverlauf bei Systemwerten (Temperatur und Spannungen) mit Datumsauswahl
|
||||
|
||||
[x] TODO: Formatierung der Kabelüberwachungswerten in den visuellen Einschüben (Isowert mit Komma und 2 Nachkommastellen; RSL mit Komma und 3 Noachkommastellen) Nachkommastellen immer anzeigen und mit Nullen auffüllen.
|
||||
|
||||
[x] TODO: lange Modulnamen bei KÜ ermöglichen (48 Zeichen) bei Version ab V4.30. Laufschrift möglich?
|
||||
|
||||
# ------------------------------------------
|
||||
|
||||
# 08.09.2025
|
||||
|
||||
[x] TODO: Beim Ausführen einer TDR-Messung (Klick auf blauen Button in der TDR-Detailseite) erscheint keine Rückmeldung. Dort müsste ein Hinweis erscheinen “TDR-Messung wird ausgeführt und kann bis zu zwei Minuten dauern”
|
||||
|
||||
## 09.09.2025
|
||||
|
||||
[x] TODO: Admin User nach einer Zeit von einer Stunde löschen (Cookie oder Local Storrage)
|
||||
|
||||
[ ] TODO: Darkmode ermöglichen
|
||||
|
||||
[ ] TODO: Wenn im Browser Darkmode eingschaltet ist muss die Webseite erkennbar sein.
|
||||
|
||||
[ ] TODO: KÜ TDR-aktiviert alert entfernen
|
||||
|
||||
[ ] TODO: Playwright testen mit der Entwicklung
|
||||
|
||||
# 11.09.2025
|
||||
|
||||
[ ] TODO: KÜ ISO Modal -> Meldungen z-index datePicker von bis
|
||||
|
||||
@@ -46,7 +46,7 @@ Sie ist **pro Slot aktivierbar** und bietet folgende Einstellungen:
|
||||
|
||||
### 🔁 Knotenpunkte-Anzeige
|
||||
|
||||
Der Reiter **Knotenpunkte** zeigt die konfigurierte Struktur eines Steckplatzes:
|
||||
Der Reiter **Knotenpunkte** zeigt die konfigurierte Struktur eines KÜes:
|
||||
|
||||
| Feld | Beschreibung |
|
||||
| -------------- | -------------------------------------------------- |
|
||||
@@ -80,6 +80,25 @@ Jeder Slot zeigt über zwei LEDs den Betriebs- und Alarmstatus an:
|
||||
|
||||
---
|
||||
|
||||
## ⏳ Messung läuft: Per-Slot Overlay (Schleife, ISO-Abgleich, TDR)
|
||||
|
||||
Während aktive Messungen laufen, wird der betroffene Slot gezielt blockiert, ohne die gesamte Seite zu sperren. So können andere Module weiterhin bedient werden.
|
||||
|
||||
- Per-Slot Overlay erscheint nur auf dem betroffenen Modul auf der Seite „Kabelüberwachung“.
|
||||
- Das Overlay zeigt für jede aktive Messart eine Fortschrittsleiste mit Prozentanzeige (ohne Restzeit):
|
||||
- Schleifenmessung (RSL, KSX) ≈ 2 Minuten
|
||||
- TDR-Messung (KSY) ≈ 30 Sekunden
|
||||
- ISO-Abgleich (KSZ) ≈ 10 Minuten
|
||||
- Die Anzeige aktualisiert sich ca. jede Sekunde.
|
||||
- Bei mehreren gleichzeitig aktiven Messarten werden mehrere Balken untereinander gezeigt.
|
||||
|
||||
Hinweise:
|
||||
|
||||
- Auf anderen Seiten (z. B. System) erscheint statt des per-Slot Overlays ein globales Overlay über die gesamte Anwendung. Auf der Seite „Kabelüberwachung“ ist dieses globale Overlay deaktiviert, damit die Bedienung der übrigen Slots möglich bleibt.
|
||||
- Die Aktivitätsinformationen stammen vom Gerät (KSX/KSY/KSZ Ereignis-Arrays je 32 Slots) und werden zyklisch eingelesen.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Messungssteuerung (manuell)
|
||||
|
||||
Im unteren Bereich jedes Slots befindet sich ein **Kreispfeil-Icon** 🔄 (Reload-Symbol):
|
||||
|
||||
@@ -46,7 +46,7 @@ Sie ist **pro Slot aktivierbar** und bietet folgende Einstellungen:
|
||||
|
||||
### 🔁 Knotenpunkte-Anzeige
|
||||
|
||||
Der Reiter **Knotenpunkte** zeigt die konfigurierte Struktur eines Steckplatzes:
|
||||
Der Reiter **Knotenpunkte** zeigt die konfigurierte Struktur eines KÜes:
|
||||
|
||||
| Feld | Beschreibung |
|
||||
| -------------- | -------------------------------------------------- |
|
||||
|
||||
@@ -5,7 +5,7 @@ Meine Tabelle ist falsch. Ich werde sie anpassen. Korrekt ist:
|
||||
108: +15V
|
||||
110: +5V
|
||||
114: -15V
|
||||
115: -98V
|
||||
115: -96V
|
||||
116: Temperatur AD Wandler
|
||||
117: Temperatur Prozessor
|
||||
------------------------------------
|
||||
@@ -8,7 +8,7 @@ In der **Systemseite** werden die aktuellen **Versorgungsspannungen** und **Temp
|
||||
|
||||
Die Seite zeigt:
|
||||
|
||||
- **Live-Werte** aller Spannungen (+5V, +15V, -15V, -98V)
|
||||
- **Live-Werte** aller Spannungen (+5V, +15V, -15V, -96V)
|
||||
- **Temperaturen** von CPU und ADC
|
||||
- **Verlauf** der Werte in einem **Liniendiagramm**
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ Sie ist **pro Slot aktivierbar** und bietet folgende Einstellungen:
|
||||
|
||||
### 🔁 Knotenpunkte-Anzeige
|
||||
|
||||
Der Reiter **Knotenpunkte** zeigt die konfigurierte Struktur eines Steckplatzes:
|
||||
Der Reiter **Knotenpunkte** zeigt die konfigurierte Struktur eines KÜes:
|
||||
|
||||
| Feld | Beschreibung |
|
||||
| -------------- | -------------------------------------------------- |
|
||||
|
||||
@@ -8,7 +8,7 @@ In der **Systemseite** werden die aktuellen **Versorgungsspannungen** und **Temp
|
||||
|
||||
Die Seite zeigt:
|
||||
|
||||
- **Live-Werte** aller Spannungen (+5V, +15V, -15V, -98V)
|
||||
- **Live-Werte** aller Spannungen (+5V, +15V, -15V, -96V)
|
||||
- **Temperaturen** von CPU und ADC
|
||||
- **Verlauf** der Werte in einem **Liniendiagramm**
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
testEnvironment: "jest-environment-jsdom",
|
||||
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
|
||||
testPathIgnorePatterns: ["/node_modules/", "/playwright/"],
|
||||
moduleNameMapper: {
|
||||
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
|
||||
"^bootstrap-icons/font/bootstrap-icons.css$":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user