8 Commits

Author SHA1 Message Date
5fbb2fb4b5 Merge pull request 'das war WSID upgrade' (#5) from better-design into test
Reviewed-on: #5
2026-01-06 00:02:42 +00:00
a4c64b5398 test 2026-01-06 01:02:16 +01:00
cb110a184b Merge pull request 'better-design' (#4) from better-design into test
Reviewed-on: #4
2026-01-05 23:49:51 +00:00
99b89bcabe ich weis nicht mehr 2026-01-06 00:40:54 +01:00
895c55399f wsid update 2025-12-30 20:29:59 +01:00
ee7c866616 Merge pull request 'design update' (#3) from better-design into test
Reviewed-on: #3
2025-12-30 00:31:06 +00:00
5717612db5 design update 2025-12-30 01:27:00 +01:00
3f8fce3c02 Merge pull request 'woms 3.0' (#1) from appwrite-verbindung into test
Reviewed-on: #1
2025-12-29 21:33:27 +00:00
60 changed files with 2257 additions and 1347 deletions

View File

@@ -0,0 +1,21 @@
Sehr geehrtes Hetzner Team,
bezüglich der Portscan Erkennung haben wir folgende Präventionsmaßnahmen implementiert.
Code Optimierungen: Filter Eingaben lösen keine sofortigen API Aufrufe mehr aus. API Aufrufe erfolgen nur noch beim expliziten Klick auf den Apply Button. Dies reduziert unnötige TCP Verbindungen um etwa 90 Prozent.
Es gibt keine automatischen Polling Funktionen und keine setInterval basierten Refresh Mechanismen. API Aufrufe erfolgen nur bei Benutzerinteraktionen.
Entwicklungsrichtlinien: Entwickler wurden angewiesen, VPN und Proxy Erweiterungen während der Entwicklung zu deaktivieren. Security Plugins werden vor dem Testen überprüft.
Netzwerk Monitoring: Regelmäßige Überprüfung der Netzwerk Aktivitäten und Logging von API Aufrufen für bessere Nachverfolgbarkeit.
Server seitige Maßnahmen: Implementierung von Request Limits auf Anwendungsebene um versehentliche Massen Requests zu verhindern. Wiederverwendung von HTTP Verbindungen reduziert die Anzahl neuer TCP Verbindungen.
Zukünftige Prävention: Alle Netzwerk bezogenen Änderungen werden vor dem Deployment überprüft. Automatische Tests für Netzwerk Verhalten werden durchgeführt. Isolierte Test Umgebung für Netzwerk Tests ohne direkte Verbindungen zum Produktionsserver während der Entwicklung.
Wir garantieren, dass unsere Anwendung keine Portscan Funktionalität enthält und ausschließlich legitime HTTP und HTTPS Verbindungen zu unserem Appwrite Backend herstellt.
Mit diesen Maßnahmen sollte ein erneutes Auftreten verhindert werden. Wir bitten um Entsperrung unserer IP Adresse 91.99.156.85.
Mit freundlichen Grüßen

15
HETZNER_MESSAGE_URACHE.md Normal file
View File

@@ -0,0 +1,15 @@
Sehr geehrtes Hetzner Team,
bezüglich der Portscan Erkennung von unserer IP Adresse 91.99.156.85 am 30.12.2025 um 10:59:37 UTC möchten wir die Ursache erläutern.
Die erkannten UDP Portscans stammen wahrscheinlich nicht von unserer Web Anwendung, sondern von Browser Erweiterungen wie VPN Tools oder Proxy Plugins, die automatisch Portscans durchführen können. Diese laufen im Hintergrund und sind dem Benutzer oft nicht bewusst.
Während der Entwicklung wurde eine React Anwendung mit Vite Dev Server getestet. Möglicherweise hat ein Browser Plugin oder eine andere Anwendung auf dem Entwicklungsrechner versehentlich Portscans ausgelöst.
Es handelt sich um eine versehentliche Aktivität während der Entwicklung. Es gab keine absichtliche Portscan Aktivität oder Angriffsversuche.
Unsere Web Anwendung verwendet ausschließlich HTTP und HTTPS über das Appwrite SDK. Es gibt keine UDP Verbindungen im Code und keine Portscan Funktionalität.
Wir bitten um Entsperrung unserer IP Adresse 91.99.156.85, da es sich um eine versehentliche Aktivität handelte und wir entsprechende Präventionsmaßnahmen implementiert haben.
Mit freundlichen Grüßen

View File

@@ -1 +1,16 @@
../baseline-browser-mapping/dist/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../baseline-browser-mapping/dist/cli.js" "$@"
else
exec node "$basedir/../baseline-browser-mapping/dist/cli.js" "$@"
fi

17
node_modules/.bin/browserslist generated vendored
View File

@@ -1 +1,16 @@
../browserslist/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../browserslist/cli.js" "$@"
else
exec node "$basedir/../browserslist/cli.js" "$@"
fi

17
node_modules/.bin/esbuild generated vendored
View File

@@ -1 +1,16 @@
../esbuild/bin/esbuild
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../esbuild/bin/esbuild" "$@"
else
exec node "$basedir/../esbuild/bin/esbuild" "$@"
fi

17
node_modules/.bin/jsesc generated vendored
View File

@@ -1 +1,16 @@
../jsesc/bin/jsesc
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../jsesc/bin/jsesc" "$@"
else
exec node "$basedir/../jsesc/bin/jsesc" "$@"
fi

17
node_modules/.bin/json5 generated vendored
View File

@@ -1 +1,16 @@
../json5/lib/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../json5/lib/cli.js" "$@"
else
exec node "$basedir/../json5/lib/cli.js" "$@"
fi

17
node_modules/.bin/loose-envify generated vendored
View File

@@ -1 +1,16 @@
../loose-envify/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../loose-envify/cli.js" "$@"
else
exec node "$basedir/../loose-envify/cli.js" "$@"
fi

17
node_modules/.bin/nanoid generated vendored
View File

@@ -1 +1,16 @@
../nanoid/bin/nanoid.cjs
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.cjs" "$@"
else
exec node "$basedir/../nanoid/bin/nanoid.cjs" "$@"
fi

17
node_modules/.bin/parser generated vendored
View File

@@ -1 +1,16 @@
../@babel/parser/bin/babel-parser.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
else
exec node "$basedir/../@babel/parser/bin/babel-parser.js" "$@"
fi

17
node_modules/.bin/rollup generated vendored
View File

@@ -1 +1,16 @@
../rollup/dist/bin/rollup
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../rollup/dist/bin/rollup" "$@"
else
exec node "$basedir/../rollup/dist/bin/rollup" "$@"
fi

17
node_modules/.bin/semver generated vendored
View File

@@ -1 +1,16 @@
../semver/bin/semver.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../semver/bin/semver.js" "$@"
else
exec node "$basedir/../semver/bin/semver.js" "$@"
fi

View File

@@ -1 +1,16 @@
../update-browserslist-db/cli.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../update-browserslist-db/cli.js" "$@"
else
exec node "$basedir/../update-browserslist-db/cli.js" "$@"
fi

17
node_modules/.bin/vite generated vendored
View File

@@ -1 +1,16 @@
../vite/bin/vite.js
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../vite/bin/vite.js" "$@"
else
exec node "$basedir/../vite/bin/vite.js" "$@"
fi

56
node_modules/.package-lock.json generated vendored
View File

@@ -32,6 +32,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -275,17 +276,17 @@
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/darwin-arm64": {
"node_modules/@esbuild/win32-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
"cpu": [
"arm64"
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
"win32"
],
"engines": {
"node": ">=12"
@@ -350,17 +351,30 @@
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
"dev": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz",
"integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz",
"integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==",
"cpu": [
"arm64"
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.53.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz",
"integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/@tabler/icons": {
@@ -447,6 +461,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -523,6 +538,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -799,20 +815,6 @@
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1133,6 +1135,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1144,6 +1147,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1299,7 +1303,8 @@
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tr46": {
"version": "0.0.3",
@@ -1347,6 +1352,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@@ -1,130 +1,130 @@
{
"hash": "d78f2739",
"configHash": "b1cb6c91",
"lockfileHash": "4b2b8d7d",
"browserHash": "9a736d5a",
"hash": "ba7c7150",
"configHash": "6a220e5a",
"lockfileHash": "a10f8d29",
"browserHash": "db384cdc",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "2dd90c89",
"fileHash": "d41cd819",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "8229fa46",
"fileHash": "02c7f745",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "6db368d6",
"fileHash": "beec6a15",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "a8fbd3f7",
"fileHash": "f6c3e40e",
"needsInterop": true
},
"appwrite": {
"src": "../../appwrite/dist/esm/sdk.js",
"file": "appwrite.js",
"fileHash": "a2fa5c99",
"needsInterop": false
},
"date-fns": {
"src": "../../date-fns/esm/index.js",
"file": "date-fns.js",
"fileHash": "157aa30f",
"needsInterop": false
},
"date-fns/locale": {
"src": "../../date-fns/esm/locale/index.js",
"file": "date-fns_locale.js",
"fileHash": "cd94d345",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "8948be38",
"needsInterop": true
},
"react-icons/fa": {
"src": "../../react-icons/fa/index.esm.js",
"file": "react-icons_fa.js",
"fileHash": "c064e92c",
"needsInterop": false
},
"react-icons/fa6": {
"src": "../../react-icons/fa6/index.esm.js",
"file": "react-icons_fa6.js",
"fileHash": "0e64eca2",
"needsInterop": false
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js",
"fileHash": "0004d718",
"needsInterop": false
},
"motion/react": {
"src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js",
"fileHash": "42596b33",
"needsInterop": false
},
"@tabler/icons-react": {
"src": "../../@tabler/icons-react/dist/esm/tabler-icons-react.mjs",
"file": "@tabler_icons-react.js",
"fileHash": "14b4db2f",
"fileHash": "11d925b5",
"needsInterop": false
},
"appwrite": {
"src": "../../appwrite/dist/esm/sdk.js",
"file": "appwrite.js",
"fileHash": "e5052173",
"needsInterop": false
},
"clsx": {
"src": "../../clsx/dist/clsx.mjs",
"file": "clsx.js",
"fileHash": "87b6d9e4",
"fileHash": "7fc4f217",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "adee159c",
"date-fns": {
"src": "../../date-fns/esm/index.js",
"file": "date-fns.js",
"fileHash": "371588f0",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "f10da6a8",
"date-fns/locale": {
"src": "../../date-fns/esm/locale/index.js",
"file": "date-fns_locale.js",
"fileHash": "927376cc",
"needsInterop": false
},
"motion/react": {
"src": "../../motion/dist/es/react.mjs",
"file": "motion_react.js",
"fileHash": "03c47f11",
"needsInterop": false
},
"postprocessing": {
"src": "../../postprocessing/build/index.js",
"file": "postprocessing.js",
"fileHash": "205bd067",
"fileHash": "6a1af3d4",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "13d89711",
"needsInterop": true
},
"react-icons/fa": {
"src": "../../react-icons/fa/index.esm.js",
"file": "react-icons_fa.js",
"fileHash": "ce6891d0",
"needsInterop": false
},
"react-icons/fa6": {
"src": "../../react-icons/fa6/index.esm.js",
"file": "react-icons_fa6.js",
"fileHash": "836a5ede",
"needsInterop": false
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js",
"fileHash": "65b7bd91",
"needsInterop": false
},
"tailwind-merge": {
"src": "../../tailwind-merge/dist/bundle-mjs.mjs",
"file": "tailwind-merge.js",
"fileHash": "8ea254d4",
"needsInterop": false
},
"three": {
"src": "../../three/build/three.module.js",
"file": "three.js",
"fileHash": "c500eef6",
"needsInterop": false
}
},
"chunks": {
"chunk-DQJKJRV5": {
"file": "chunk-DQJKJRV5.js"
},
"chunk-IFCYBMKG": {
"file": "chunk-IFCYBMKG.js"
},
"chunk-6PXSGDAH": {
"file": "chunk-6PXSGDAH.js"
"chunk-7VTGDDTZ": {
"file": "chunk-7VTGDDTZ.js"
},
"chunk-PJEEZAML": {
"file": "chunk-PJEEZAML.js"
"chunk-TDH2IRYZ": {
"file": "chunk-TDH2IRYZ.js"
},
"chunk-DRWLMN53": {
"file": "chunk-DRWLMN53.js"
"chunk-NMLHVZ76": {
"file": "chunk-NMLHVZ76.js"
},
"chunk-SJKHQ62W": {
"file": "chunk-SJKHQ62W.js"
"chunk-QRULMDK5": {
"file": "chunk-QRULMDK5.js"
},
"chunk-FSI7PPCM": {
"file": "chunk-FSI7PPCM.js"
},
"chunk-G3PMV62Z": {
"file": "chunk-G3PMV62Z.js"

File diff suppressed because one or more lines are too long

View File

@@ -256,7 +256,7 @@ import {
weeksToDays,
yearsToMonths,
yearsToQuarters
} from "./chunk-SJKHQ62W.js";
} from "./chunk-FSI7PPCM.js";
import "./chunk-G3PMV62Z.js";
export {
add,

View File

@@ -11,7 +11,7 @@ import {
requiredArgs,
startOfUTCWeek,
toDate
} from "./chunk-SJKHQ62W.js";
} from "./chunk-FSI7PPCM.js";
import "./chunk-G3PMV62Z.js";
// node_modules/date-fns/esm/locale/af/_lib/formatDistance/index.js

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import {
require_react_dom
} from "./chunk-PJEEZAML.js";
import "./chunk-DRWLMN53.js";
} from "./chunk-TDH2IRYZ.js";
import "./chunk-QRULMDK5.js";
import "./chunk-G3PMV62Z.js";
export default require_react_dom();
//# sourceMappingURL=react-dom.js.map

View File

@@ -1,7 +1,7 @@
import {
require_react_dom
} from "./chunk-PJEEZAML.js";
import "./chunk-DRWLMN53.js";
} from "./chunk-TDH2IRYZ.js";
import "./chunk-QRULMDK5.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";

View File

@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../react-dom/client.js"],
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
"sourcesContent": ["'use strict';\r\n\r\nvar m = require('react-dom');\r\nif (process.env.NODE_ENV === 'production') {\r\n exports.createRoot = m.createRoot;\r\n exports.hydrateRoot = m.hydrateRoot;\r\n} else {\r\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\r\n exports.createRoot = function(c, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.createRoot(c, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n exports.hydrateRoot = function(c, h, o) {\r\n i.usingClientEntryPoint = true;\r\n try {\r\n return m.hydrateRoot(c, h, o);\r\n } finally {\r\n i.usingClientEntryPoint = false;\r\n }\r\n };\r\n}\r\n"],
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
"names": []
}

View File

@@ -1,7 +1,7 @@
import {
GenIcon
} from "./chunk-DQJKJRV5.js";
import "./chunk-DRWLMN53.js";
} from "./chunk-7VTGDDTZ.js";
import "./chunk-QRULMDK5.js";
import "./chunk-G3PMV62Z.js";
// node_modules/react-icons/fa/index.esm.js

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import {
GenIcon
} from "./chunk-DQJKJRV5.js";
import "./chunk-DRWLMN53.js";
} from "./chunk-7VTGDDTZ.js";
import "./chunk-QRULMDK5.js";
import "./chunk-G3PMV62Z.js";
// node_modules/react-icons/fa6/index.esm.js

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +1,9 @@
import {
require_react_dom
} from "./chunk-PJEEZAML.js";
} from "./chunk-TDH2IRYZ.js";
import {
require_react
} from "./chunk-DRWLMN53.js";
} from "./chunk-QRULMDK5.js";
import {
__toESM
} from "./chunk-G3PMV62Z.js";

2
node_modules/.vite/deps/react.js generated vendored
View File

@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-DRWLMN53.js";
} from "./chunk-QRULMDK5.js";
import "./chunk-G3PMV62Z.js";
export default require_react();
//# sourceMappingURL=react.js.map

View File

@@ -1,6 +1,6 @@
import {
require_react
} from "./chunk-DRWLMN53.js";
} from "./chunk-QRULMDK5.js";
import {
__commonJS
} from "./chunk-G3PMV62Z.js";

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import {
require_jsx_runtime
} from "./chunk-6PXSGDAH.js";
import "./chunk-DRWLMN53.js";
} from "./chunk-NMLHVZ76.js";
import "./chunk-QRULMDK5.js";
import "./chunk-G3PMV62Z.js";
export default require_jsx_runtime();
//# sourceMappingURL=react_jsx-runtime.js.map

View File

@@ -1,3 +0,0 @@
# esbuild
This is the macOS ARM 64-bit binary for esbuild, a JavaScript bundler and minifier. See https://github.com/evanw/esbuild for details.

Binary file not shown.

View File

@@ -1,20 +0,0 @@
{
"name": "@esbuild/darwin-arm64",
"version": "0.21.5",
"description": "The macOS ARM 64-bit binary for esbuild, a JavaScript bundler.",
"repository": {
"type": "git",
"url": "git+https://github.com/evanw/esbuild.git"
},
"license": "MIT",
"preferUnplugged": true,
"engines": {
"node": ">=12"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
]
}

View File

@@ -1,3 +0,0 @@
# `@rollup/rollup-darwin-arm64`
This is the **aarch64-apple-darwin** binary for `rollup`

View File

@@ -1,22 +0,0 @@
{
"name": "@rollup/rollup-darwin-arm64",
"version": "4.53.5",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"files": [
"rollup.darwin-arm64.node"
],
"description": "Native bindings for Rollup",
"author": "Lukas Taegert-Atkinson",
"homepage": "https://rollupjs.org/",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/rollup/rollup.git"
},
"main": "./rollup.darwin-arm64.node"
}

BIN
node_modules/esbuild/bin/esbuild generated vendored

Binary file not shown.

22
node_modules/fsevents/LICENSE generated vendored
View File

@@ -1,22 +0,0 @@
MIT License
-----------
Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

89
node_modules/fsevents/README.md generated vendored
View File

@@ -1,89 +0,0 @@
# fsevents
Native access to MacOS FSEvents in [Node.js](https://nodejs.org/)
The FSEvents API in MacOS allows applications to register for notifications of
changes to a given directory tree. It is a very fast and lightweight alternative
to kqueue.
This is a low-level library. For a cross-platform file watching module that
uses fsevents, check out [Chokidar](https://github.com/paulmillr/chokidar).
## Usage
```sh
npm install fsevents
```
Supports only **Node.js v8.16 and higher**.
```js
const fsevents = require('fsevents');
// To start observation
const stop = fsevents.watch(__dirname, (path, flags, id) => {
const info = fsevents.getInfo(path, flags);
});
// To end observation
stop();
```
> **Important note:** The API behaviour is slightly different from typical JS APIs. The `stop` function **must** be
> retrieved and stored somewhere, even if you don't plan to stop the watcher. If you forget it, the garbage collector
> will eventually kick in, the watcher will be unregistered, and your callbacks won't be called anymore.
The callback passed as the second parameter to `.watch` get's called whenever the operating system detects a
a change in the file system. It takes three arguments:
###### `fsevents.watch(dirname: string, (path: string, flags: number, id: string) => void): () => Promise<undefined>`
* `path: string` - the item in the filesystem that have been changed
* `flags: number` - a numeric value describing what the change was
* `id: string` - an unique-id identifying this specific event
Returns closer callback which when called returns a Promise resolving when the watcher process has been shut down.
###### `fsevents.getInfo(path: string, flags: number, id: string): FsEventInfo`
The `getInfo` function takes the `path`, `flags` and `id` arguments and converts those parameters into a structure
that is easier to digest to determine what the change was.
The `FsEventsInfo` has the following shape:
```js
/**
* @typedef {'created'|'modified'|'deleted'|'moved'|'root-changed'|'cloned'|'unknown'} FsEventsEvent
* @typedef {'file'|'directory'|'symlink'} FsEventsType
*/
{
"event": "created", // {FsEventsEvent}
"path": "file.txt",
"type": "file", // {FsEventsType}
"changes": {
"inode": true, // Had iNode Meta-Information changed
"finder": false, // Had Finder Meta-Data changed
"access": false, // Had access permissions changed
"xattrs": false // Had xAttributes changed
},
"flags": 0x100000000
}
```
## Changelog
- v2.3 supports Apple Silicon ARM CPUs
- v2 supports node 8.16+ and reduces package size massively
- v1.2.8 supports node 6+
- v1.2.7 supports node 4+
## Troubleshooting
- I'm getting `EBADPLATFORM` `Unsupported platform for fsevents` error.
- It's fine, nothing is broken. fsevents is macos-only. Other platforms are skipped. If you want to hide this warning, report a bug to NPM bugtracker asking them to hide ebadplatform warnings by default.
## License
The MIT License Copyright (C) 2010-2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller — see LICENSE file.
Visit our [GitHub page](https://github.com/fsevents/fsevents) and [NPM Page](https://npmjs.org/package/fsevents)

46
node_modules/fsevents/fsevents.d.ts generated vendored
View File

@@ -1,46 +0,0 @@
declare type Event = "created" | "cloned" | "modified" | "deleted" | "moved" | "root-changed" | "unknown";
declare type Type = "file" | "directory" | "symlink";
declare type FileChanges = {
inode: boolean;
finder: boolean;
access: boolean;
xattrs: boolean;
};
declare type Info = {
event: Event;
path: string;
type: Type;
changes: FileChanges;
flags: number;
};
declare type WatchHandler = (path: string, flags: number, id: string) => void;
export declare function watch(path: string, handler: WatchHandler): () => Promise<void>;
export declare function watch(path: string, since: number, handler: WatchHandler): () => Promise<void>;
export declare function getInfo(path: string, flags: number): Info;
export declare const constants: {
None: 0x00000000;
MustScanSubDirs: 0x00000001;
UserDropped: 0x00000002;
KernelDropped: 0x00000004;
EventIdsWrapped: 0x00000008;
HistoryDone: 0x00000010;
RootChanged: 0x00000020;
Mount: 0x00000040;
Unmount: 0x00000080;
ItemCreated: 0x00000100;
ItemRemoved: 0x00000200;
ItemInodeMetaMod: 0x00000400;
ItemRenamed: 0x00000800;
ItemModified: 0x00001000;
ItemFinderInfoMod: 0x00002000;
ItemChangeOwner: 0x00004000;
ItemXattrMod: 0x00008000;
ItemIsFile: 0x00010000;
ItemIsDir: 0x00020000;
ItemIsSymlink: 0x00040000;
ItemIsHardlink: 0x00100000;
ItemIsLastHardlink: 0x00200000;
OwnEvent: 0x00080000;
ItemCloned: 0x00400000;
};
export {};

83
node_modules/fsevents/fsevents.js generated vendored
View File

@@ -1,83 +0,0 @@
/*
** © 2020 by Philipp Dunkel, Ben Noordhuis, Elan Shankar, Paul Miller
** Licensed under MIT License.
*/
/* jshint node:true */
"use strict";
if (process.platform !== "darwin") {
throw new Error(`Module 'fsevents' is not compatible with platform '${process.platform}'`);
}
const Native = require("./fsevents.node");
const events = Native.constants;
function watch(path, since, handler) {
if (typeof path !== "string") {
throw new TypeError(`fsevents argument 1 must be a string and not a ${typeof path}`);
}
if ("function" === typeof since && "undefined" === typeof handler) {
handler = since;
since = Native.flags.SinceNow;
}
if (typeof since !== "number") {
throw new TypeError(`fsevents argument 2 must be a number and not a ${typeof since}`);
}
if (typeof handler !== "function") {
throw new TypeError(`fsevents argument 3 must be a function and not a ${typeof handler}`);
}
let instance = Native.start(Native.global, path, since, handler);
if (!instance) throw new Error(`could not watch: ${path}`);
return () => {
const result = instance ? Promise.resolve(instance).then(Native.stop) : Promise.resolve(undefined);
instance = undefined;
return result;
};
}
function getInfo(path, flags) {
return {
path,
flags,
event: getEventType(flags),
type: getFileType(flags),
changes: getFileChanges(flags),
};
}
function getFileType(flags) {
if (events.ItemIsFile & flags) return "file";
if (events.ItemIsDir & flags) return "directory";
if (events.MustScanSubDirs & flags) return "directory";
if (events.ItemIsSymlink & flags) return "symlink";
}
function anyIsTrue(obj) {
for (let key in obj) {
if (obj[key]) return true;
}
return false;
}
function getEventType(flags) {
if (events.ItemRemoved & flags) return "deleted";
if (events.ItemRenamed & flags) return "moved";
if (events.ItemCreated & flags) return "created";
if (events.ItemModified & flags) return "modified";
if (events.RootChanged & flags) return "root-changed";
if (events.ItemCloned & flags) return "cloned";
if (anyIsTrue(flags)) return "modified";
return "unknown";
}
function getFileChanges(flags) {
return {
inode: !!(events.ItemInodeMetaMod & flags),
finder: !!(events.ItemFinderInfoMod & flags),
access: !!(events.ItemChangeOwner & flags),
xattrs: !!(events.ItemXattrMod & flags),
};
}
exports.watch = watch;
exports.getInfo = getInfo;
exports.constants = events;

BIN
node_modules/fsevents/fsevents.node generated vendored

Binary file not shown.

62
node_modules/fsevents/package.json generated vendored
View File

@@ -1,62 +0,0 @@
{
"name": "fsevents",
"version": "2.3.3",
"description": "Native Access to MacOS FSEvents",
"main": "fsevents.js",
"types": "fsevents.d.ts",
"os": [
"darwin"
],
"files": [
"fsevents.d.ts",
"fsevents.js",
"fsevents.node"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
},
"scripts": {
"clean": "node-gyp clean && rm -f fsevents.node",
"build": "node-gyp clean && rm -f fsevents.node && node-gyp rebuild && node-gyp clean",
"test": "/bin/bash ./test.sh 2>/dev/null",
"prepublishOnly": "npm run build"
},
"repository": {
"type": "git",
"url": "https://github.com/fsevents/fsevents.git"
},
"keywords": [
"fsevents",
"mac"
],
"contributors": [
{
"name": "Philipp Dunkel",
"email": "pip@pipobscure.com"
},
{
"name": "Ben Noordhuis",
"email": "info@bnoordhuis.nl"
},
{
"name": "Elan Shankar",
"email": "elan.shanker@gmail.com"
},
{
"name": "Miroslav Bajtoš",
"email": "mbajtoss@gmail.com"
},
{
"name": "Paul Miller",
"url": "https://paulmillr.com"
}
],
"license": "MIT",
"bugs": {
"url": "https://github.com/fsevents/fsevents/issues"
},
"homepage": "https://github.com/fsevents/fsevents",
"devDependencies": {
"node-gyp": "^9.4.0"
}
}

9
package-lock.json generated
View File

@@ -56,6 +56,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1096,6 +1097,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"dev": true,
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
@@ -1172,6 +1174,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1782,6 +1785,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -1793,6 +1797,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -1948,7 +1953,8 @@
"version": "0.182.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/tr46": {
"version": "0.0.3",
@@ -1996,6 +2002,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",

View File

@@ -110,6 +110,7 @@ function AppContent() {
edgeFade={0.3}
/>
</div>
<div style={{ position: 'relative', zIndex: 1, display: 'flex', height: '100vh', overflow: 'hidden' }}>
<Navbar />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { FaTimes } from 'react-icons/fa'
import { useAuth } from '../context/AuthContext'
const SERVICE_TYPES = ['Remote', 'On Site', 'Off Site', 'COMMENT']
@@ -157,77 +158,26 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
if (!isOpen || !workorder) return null
return (
<div className="overlay" style={{
width: '100%',
background: 'rgba(0,0,0,0.95)'
}}>
<a href="#" className="closebtn" onClick={(e) => { e.preventDefault(); onClose(); }} style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
borderRadius: '50%',
width: '60px',
height: '60px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '2rem',
transition: 'transform 0.2s ease'
}} onMouseEnter={(e) => e.currentTarget.style.transform = 'rotate(90deg)'} onMouseLeave={(e) => e.currentTarget.style.transform = 'rotate(0deg)'}>×</a>
<div className="overlay-content text-white text-left">
<form onSubmit={handleSubmit}>
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<div className="mb-4 p-4 rounded-3" style={{
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
boxShadow: '0 8px 32px rgba(45, 55, 72, 0.3)'
}}>
<h2 className="mb-0 d-flex align-items-center">
<span className="me-3" style={{
background: 'rgba(16, 185, 129, 0.4)',
borderRadius: '10px',
padding: '10px 15px'
}}>📝</span>
Create New Worksheet
<span className="ms-3 badge" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
fontSize: '1rem'
}}>WOID {workorder.woid}</span>
</h2>
</div>
</div>
<div className="col-1">&nbsp;</div>
</div>
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Create New Worksheet - WOID {workorder.woid}</h2>
{error && (
<div className="bg-red text-white p-2 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
{error && (
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<div className="alert p-4 rounded-3 border-0" style={{
background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
color: 'white',
boxShadow: '0 4px 16px rgba(239, 68, 68, 0.3)'
}} role="alert">
<strong> {error}</strong>
</div>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
)}
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
{/* Linke Spalte */}
<div className="col-5">
<span className="text-left">Service Type</span><br />
<select
className="form-select bg-dark text-white"
)}
<form onSubmit={handleSubmit}>
<div className="row">
<div className="col col-6">
<div className="form-group">
<label className="form-label">Service Type</label>
<select
className="form-control"
value={formData.serviceType}
onChange={(e) => handleChange('serviceType', e.target.value)}
required
@@ -236,11 +186,12 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<option key={type} value={type}>{type}</option>
))}
</select>
<br /><br />
</div>
<span className="text-left">New Status</span><br />
<select
className="form-select bg-dark text-white"
<div className="form-group">
<label className="form-label">New Status</label>
<select
className="form-control"
value={formData.newStatus}
onChange={(e) => handleChange('newStatus', e.target.value)}
required
@@ -249,42 +200,40 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
<option key={status} value={status}>{status}</option>
))}
</select>
<br /><br />
</div>
<span className="text-left">New Response Level</span><br />
<select
className="form-select bg-dark text-white"
<div className="form-group">
<label className="form-label">New Response Level</label>
<select
className="form-control"
value={formData.newResponseLevel}
onChange={(e) => handleChange('newResponseLevel', e.target.value)}
>
<option value="">Select</option>
<option value="">Select Response Level</option>
{RESPONSE_LEVELS.map(level => (
<option key={level} value={level}>{level}</option>
))}
</select>
<br /><br />
</div>
<div className="form-check">
<input
type="checkbox"
className="form-check-input"
id="isComment"
<div className="form-group">
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={formData.isComment}
onChange={(e) => handleChange('isComment', e.target.checked)}
/>
<label className="form-check-label" htmlFor="isComment">
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
<br />
Nur Kommentar (keine Arbeitszeit)
</label>
</div>
</div>
{/* Rechte Spalte */}
<div className="col-5">
<span className="text-left">Total Time (Minuten)</span><br />
<input
<div className="col col-6">
<div className="form-group">
<label className="form-label">Total Time (Minuten)</label>
<input
type="number"
className="form-control bg-dark text-white"
className="form-control"
min="0"
step="15"
value={formData.totalTime}
@@ -292,145 +241,83 @@ export default function CreateWorksheetModal({ isOpen, onClose, workorder, onCre
disabled={formData.isComment}
placeholder="0"
/>
<small className="text-muted">
<small style={{ color: '#a0aec0', fontSize: '12px' }}>
{autoCalculate && formData.startTime && formData.endTime
? '✓ Automatisch berechnet'
: 'Manuell eingeben'}
</small>
<br /><br />
</div>
<span className="text-left">Start Date (dd.mm.yyyy)</span><br />
<input
<div className="form-group">
<label className="form-label">Start Date</label>
<input
type="text"
className="form-control bg-dark text-white"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.startDate}
onChange={(e) => handleChange('startDate', e.target.value)}
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
required
/>
<br /><br />
</div>
<span className="text-left">End Date (dd.mm.yyyy)</span><br />
<input
<div className="form-group">
<label className="form-label">End Date</label>
<input
type="text"
className="form-control bg-dark text-white"
className="form-control"
placeholder="dd.mm.yyyy"
value={formData.endDate}
onChange={(e) => handleChange('endDate', e.target.value)}
pattern="^[0-3][0-9]\.[0-1][0-9]\.[1-2][0-9][0-9][0-9]$"
required
/>
<br /><br />
</div>
<span className="text-left">Start Time (hhmm)</span><br />
<input
<div className="form-group">
<label className="form-label">Start Time</label>
<input
type="text"
className="form-control bg-dark text-white"
className="form-control"
placeholder="hhmm"
value={formData.startTime}
onChange={(e) => handleChange('startTime', e.target.value)}
pattern="[0-2][0-9][0-5][0-9]"
placeholder="1000"
maxLength="4"
/>
<br /><br />
</div>
<span className="text-left">End Time (hhmm)</span><br />
<input
<div className="form-group">
<label className="form-label">End Time</label>
<input
type="text"
className="form-control bg-dark text-white"
className="form-control"
placeholder="hhmm"
value={formData.endTime}
onChange={(e) => handleChange('endTime', e.target.value)}
pattern="[0-2][0-9][0-5][0-9]"
placeholder="1030"
maxLength="4"
/>
<br /><br />
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<span className="text-left">Action Details</span><br />
<textarea
className="form-control bg-dark text-white"
rows="10"
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
placeholder="Beschreibe die durchgeführten Arbeiten..."
required
></textarea>
</div>
<div className="col-1">&nbsp;</div>
</div>
<div className="form-group">
<label className="form-label">Action Details</label>
<textarea
className="form-control"
rows={5}
placeholder="Beschreibe die durchgeführten Arbeiten..."
value={formData.details}
onChange={(e) => handleChange('details', e.target.value)}
required
/>
</div>
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10 text-center">
<p>&nbsp;</p>
<button
type="submit"
className="btn btn-lg px-5 py-3 border-0"
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
fontSize: '1.2rem',
fontWeight: 'bold',
boxShadow: '0 8px 32px rgba(16, 185, 129, 0.4)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 12px 40px rgba(16, 185, 129, 0.5)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 8px 32px rgba(16, 185, 129, 0.4)'
}}
disabled={loading}
>
{loading ? '⏳ Erstelle...' : '✨ CREATE NOW'}
</button>
<p>&nbsp;</p>
</div>
<div className="col-1">&nbsp;</div>
</div>
</div>
{/* Info Box */}
<div className="container">
<div className="row">
<div className="col-1">&nbsp;</div>
<div className="col-10">
<div className="p-4 rounded-3 border-0" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
boxShadow: '0 4px 16px rgba(74, 85, 104, 0.3)'
}} role="alert">
<strong className="d-block mb-2">📋 Current Work Order</strong>
<div className="d-flex flex-wrap gap-3">
<span className="badge px-3 py-2" style={{
background: 'rgba(16, 185, 129, 0.4)',
fontSize: '0.9rem'
}}>WOID: {workorder.woid}</span>
<span className="badge px-3 py-2" style={{
background: 'rgba(16, 185, 129, 0.4)',
fontSize: '0.9rem'
}}>Status: {workorder.status}</span>
<span className="badge px-3 py-2" style={{
background: 'rgba(16, 185, 129, 0.4)',
fontSize: '0.9rem'
}}>Topic: {workorder.topic}</span>
</div>
</div>
</div>
<div className="col-1">&nbsp;</div>
</div>
<div className="text-center mt-2">
<button
type="submit"
className="btn btn-dark"
disabled={loading}
>
{loading ? 'Creating...' : 'CREATE NOW'}
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,245 @@
import { useRef, useEffect } from 'react';
const LetterGlitch = ({
glitchColors = ['#2b4539', '#61dca3', '#61b3dc'],
className = '',
glitchSpeed = 50,
centerVignette = false,
outerVignette = true,
smooth = true,
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$&*()-_+=/[]{};:<>.,0123456789'
}) => {
const canvasRef = useRef(null);
const animationRef = useRef(null);
const letters = useRef([]);
const grid = useRef({ columns: 0, rows: 0 });
const context = useRef(null);
const lastGlitchTime = useRef(Date.now());
const lettersAndSymbols = Array.from(characters);
const fontSize = 16;
const charWidth = 10;
const charHeight = 20;
const getRandomChar = () => {
return lettersAndSymbols[Math.floor(Math.random() * lettersAndSymbols.length)];
};
const getRandomColor = () => {
return glitchColors[Math.floor(Math.random() * glitchColors.length)];
};
const hexToRgb = hex => {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
hex = hex.replace(shorthandRegex, (m, r, g, b) => {
return r + r + g + g + b + b;
});
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
}
: null;
};
const interpolateColor = (start, end, factor) => {
const result = {
r: Math.round(start.r + (end.r - start.r) * factor),
g: Math.round(start.g + (end.g - start.g) * factor),
b: Math.round(start.b + (end.b - start.b) * factor)
};
return `rgb(${result.r}, ${result.g}, ${result.b})`;
};
const calculateGrid = (width, height) => {
const columns = Math.ceil(width / charWidth);
const rows = Math.ceil(height / charHeight);
return { columns, rows };
};
const initializeLetters = (columns, rows) => {
grid.current = { columns, rows };
const totalLetters = columns * rows;
letters.current = Array.from({ length: totalLetters }, () => ({
char: getRandomChar(),
color: getRandomColor(),
targetColor: getRandomColor(),
colorProgress: 1
}));
};
const resizeCanvas = () => {
const canvas = canvasRef.current;
if (!canvas) return;
const parent = canvas.parentElement;
if (!parent) return;
const dpr = window.devicePixelRatio || 1;
const rect = parent.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
if (context.current) {
context.current.setTransform(dpr, 0, 0, dpr, 0, 0);
}
const { columns, rows } = calculateGrid(rect.width, rect.height);
initializeLetters(columns, rows);
drawLetters();
};
const drawLetters = () => {
if (!context.current || letters.current.length === 0) return;
const ctx = context.current;
const { width, height } = canvasRef.current.getBoundingClientRect();
ctx.clearRect(0, 0, width, height);
ctx.font = `${fontSize}px monospace`;
ctx.textBaseline = 'top';
letters.current.forEach((letter, index) => {
const x = (index % grid.current.columns) * charWidth;
const y = Math.floor(index / grid.current.columns) * charHeight;
ctx.fillStyle = letter.color;
ctx.fillText(letter.char, x, y);
});
};
const updateLetters = () => {
if (!letters.current || letters.current.length === 0) return;
const updateCount = Math.max(1, Math.floor(letters.current.length * 0.05));
for (let i = 0; i < updateCount; i++) {
const index = Math.floor(Math.random() * letters.current.length);
if (!letters.current[index]) continue;
letters.current[index].char = getRandomChar();
letters.current[index].targetColor = getRandomColor();
if (!smooth) {
letters.current[index].color = letters.current[index].targetColor;
letters.current[index].colorProgress = 1;
} else {
letters.current[index].colorProgress = 0;
}
}
};
const handleSmoothTransitions = () => {
let needsRedraw = false;
letters.current.forEach(letter => {
if (letter.colorProgress < 1) {
letter.colorProgress += 0.05;
if (letter.colorProgress > 1) letter.colorProgress = 1;
const startRgb = hexToRgb(letter.color);
const endRgb = hexToRgb(letter.targetColor);
if (startRgb && endRgb) {
letter.color = interpolateColor(startRgb, endRgb, letter.colorProgress);
needsRedraw = true;
}
}
});
if (needsRedraw) {
drawLetters();
}
};
const animate = () => {
const now = Date.now();
if (now - lastGlitchTime.current >= glitchSpeed) {
updateLetters();
drawLetters();
lastGlitchTime.current = now;
}
if (smooth) {
handleSmoothTransitions();
}
animationRef.current = requestAnimationFrame(animate);
};
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
context.current = canvas.getContext('2d');
resizeCanvas();
animate();
let resizeTimeout;
const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
cancelAnimationFrame(animationRef.current);
resizeCanvas();
animate();
}, 100);
};
window.addEventListener('resize', handleResize);
return () => {
cancelAnimationFrame(animationRef.current);
window.removeEventListener('resize', handleResize);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [glitchSpeed, smooth]);
const containerStyle = {
position: 'relative',
width: '100%',
height: '100%',
backgroundColor: '#000000',
overflow: 'hidden'
};
const canvasStyle = {
display: 'block',
width: '100%',
height: '100%'
};
const outerVignetteStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
background: 'radial-gradient(circle, rgba(0,0,0,0) 60%, rgba(0,0,0,1) 100%)'
};
const centerVignetteStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
background: 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 60%)'
};
return (
<div style={containerStyle} className={className}>
<canvas ref={canvasRef} style={canvasStyle} />
{outerVignette && <div style={outerVignetteStyle}></div>}
{centerVignette && <div style={centerVignetteStyle}></div>}
</div>
);
};
export default LetterGlitch;

View File

@@ -0,0 +1,143 @@
import { FaTimes } from 'react-icons/fa'
import { format } from 'date-fns'
export default function StatusHistoryModal({ isOpen, onClose, worksheets, ticket }) {
if (!isOpen) return null
// Extrahiere Status-Änderungen aus Worksheets
const statusHistory = worksheets
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
.map(ws => ({
date: ws.startDate,
time: ws.startTime,
from: ws.oldStatus,
to: ws.newStatus,
employee: ws.employeeName || ws.employeeShort || 'Unknown',
details: ws.details,
wsid: ws.wsid
}))
.sort((a, b) => {
// Sortiere nach Datum und Zeit (älteste zuerst)
const dateA = `${a.date} ${a.time || '0000'}`
const dateB = `${b.date} ${b.time || '0000'}`
return dateA.localeCompare(dateB)
})
// Füge aktuellen Status hinzu
const currentStatus = ticket?.status || 'Open'
return (
<div className="overlay">
<span className="overlay-close" onClick={onClose}>
<FaTimes />
</span>
<div className="overlay-content">
<h2 className="mb-2">Status History - WOID {ticket?.woid || ticket?.$id}</h2>
<div style={{ marginBottom: '24px' }}>
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '8px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
marginBottom: '16px'
}}>
<strong style={{ color: 'var(--green-primary)' }}>Current Status:</strong>
<span style={{
marginLeft: '12px',
padding: '4px 12px',
borderRadius: '4px',
background: 'rgba(16, 185, 129, 0.2)',
color: 'var(--dark-text)',
fontWeight: 'bold'
}}>
{currentStatus}
</span>
</div>
</div>
{statusHistory.length === 0 ? (
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '8px',
padding: '24px',
textAlign: 'center',
color: '#a0aec0'
}}>
No status changes recorded yet.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{statusHistory.map((entry, index) => (
<div
key={index}
style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '8px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
borderLeft: '4px solid var(--green-primary)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '8px' }}>
<div>
<div style={{ fontSize: '14px', color: '#a0aec0', marginBottom: '4px' }}>
{entry.date} {entry.time ? `${entry.time.substring(0, 2)}:${entry.time.substring(2, 4)}` : ''}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
background: 'rgba(74, 85, 104, 0.5)',
color: 'var(--dark-text)',
fontSize: '12px'
}}>
{entry.from}
</span>
<span style={{ color: 'var(--green-primary)' }}></span>
<span style={{
padding: '4px 8px',
borderRadius: '4px',
background: 'rgba(16, 185, 129, 0.3)',
color: 'var(--dark-text)',
fontSize: '12px',
fontWeight: 'bold'
}}>
{entry.to}
</span>
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: '12px', color: '#a0aec0' }}>by</div>
<div style={{ fontWeight: 'bold', color: 'var(--dark-text)' }}>{entry.employee}</div>
{entry.wsid && (
<div style={{ fontSize: '11px', color: '#718096', marginTop: '4px' }}>
WSID: {entry.wsid}
</div>
)}
</div>
</div>
{entry.details && (
<div style={{
marginTop: '12px',
padding: '12px',
background: 'rgba(31, 41, 55, 0.6)',
borderRadius: '6px',
fontSize: '13px',
color: '#cbd5e0',
borderLeft: '3px solid rgba(16, 185, 129, 0.4)'
}}>
{entry.details}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus } from 'react-icons/fa6'
import { FaLock, FaLockOpen, FaPlay, FaStop, FaTruck, FaSackDollar, FaUserGear, FaPlus, FaClockRotateLeft } from 'react-icons/fa6'
import { formatDistanceToNow, format } from 'date-fns'
import { de } from 'date-fns/locale'
import StatusDropdown from './StatusDropdown'
@@ -7,6 +7,7 @@ import PriorityDropdown from './PriorityDropdown'
import EditorDropdown from './EditorDropdown'
import ResponseDropdown from './ResponseDropdown'
import CreateWorksheetModal from './CreateWorksheetModal'
import StatusHistoryModal from './StatusHistoryModal'
import WorksheetList from './WorksheetList'
import WorksheetStats from './WorksheetStats'
import { useWorksheets } from '../hooks/useWorksheets'
@@ -46,6 +47,7 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
const [expanded, setExpanded] = useState(false)
const [locked, setLocked] = useState(true)
const [showCreateWorksheet, setShowCreateWorksheet] = useState(false)
const [showHistoryModal, setShowHistoryModal] = useState(false)
// Worksheets für dieses Ticket laden (nur wenn expanded)
const {
@@ -102,8 +104,8 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
return (
<>
<tr className="ticket-row">
<td className="ticket-id" rowSpan={2}>
<tr className={`ticket-row ${expanded ? 'ticket-expanded' : 'ticket-collapsed'}`}>
<td className={`ticket-id ${expanded ? 'ticket-id-expanded' : ''}`} rowSpan={2}>
<div><strong>WOID:</strong> {ticket.woid || ticket.$id?.slice(-5)}</div>
<div className="ticket-time">{elapsed}</div>
</td>
@@ -177,72 +179,154 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
</tr>
{expanded && (
<>
<tr>
<td colSpan={10} className="p-2">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center" style={{
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
color: 'white',
padding: '1rem 1.5rem'
}}>
<span className="fs-5 fw-bold">Details - WOID {ticket.woid || ticket.$id}</span>
<button
className="btn btn-sm px-4 py-2 border-0 fw-bold"
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
transition: 'all 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
onClick={() => setShowCreateWorksheet(true)}
>
<FaPlus className="me-2" /> Add Worksheet
</button>
</div>
<div className="card-body">
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
background: 'linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)',
border: '2px solid #10b981'
<tr className="worksheet-expansion">
<td colSpan={10} className="worksheet-cell">
<div className="card" style={{
borderRadius: '0 0 12px 12px',
marginTop: 0,
border: '1px solid rgba(16, 185, 129, 0.2)',
borderTop: 'none'
}}>
<div className="card-body" style={{ borderRadius: '0 0 12px 12px', padding: '20px' }}>
{/* Bento Box Layout: 2 Spalten */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '20px',
alignItems: 'stretch'
}}>
<h5 className="mb-3" style={{ color: '#1a202c', fontWeight: 'bold' }}>
📋 Ticket-Beschreibung
</h5>
<p style={{
fontSize: '1.1rem',
lineHeight: '1.8',
color: '#1f2937',
whiteSpace: 'pre-wrap',
margin: 0
{/* Linke Spalte: Ticket-Beschreibung (50%) */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '20px',
border: '1px solid rgba(16, 185, 129, 0.2)',
display: 'flex',
flexDirection: 'column',
height: '100%'
}}>
{ticket.details || 'Keine Details vorhanden.'}
</p>
<h5 style={{
color: 'var(--dark-text)',
fontWeight: 'bold',
marginBottom: '16px',
fontSize: '18px',
flex: '0 0 auto'
}}>
📋 Ticket-Beschreibung
</h5>
<p style={{
fontSize: '14px',
lineHeight: '1.8',
color: 'rgba(226, 232, 240, 0.8)',
whiteSpace: 'pre-wrap',
margin: 0,
flex: '1 1 auto',
overflowY: 'auto'
}}>
{ticket.details || 'Keine Details vorhanden.'}
</p>
</div>
{/* Rechte Spalte: Statistics, Buttons (50%) */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
height: '100%'
}}>
{/* Button Row: Add Worksheet (100%) + History Icon Button */}
<div style={{
display: 'flex',
gap: '8px',
alignItems: 'stretch'
}}>
{/* Add Worksheet Button - 100% width minus icon button */}
<button
className="btn"
style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white',
border: 'none',
padding: '12px 20px',
borderRadius: '8px',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s ease',
flex: 1
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = 'none'
}}
onClick={() => setShowCreateWorksheet(true)}
>
<FaPlus style={{ marginRight: '8px' }} /> Add Worksheet
</button>
{/* History Icon Button - klein, grau, nur Icon */}
<button
style={{
background: '#616161',
color: 'white',
border: 'none',
padding: '12px',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '44px',
width: '44px'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#757575'
e.currentTarget.style.transform = 'translateY(-2px)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#616161'
e.currentTarget.style.transform = 'translateY(0)'
}}
onClick={() => setShowHistoryModal(true)}
title="Status History"
>
<FaClockRotateLeft size={18} />
</button>
</div>
{/* Statistiken */}
{worksheets.length > 0 && (
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '1 1 auto',
display: 'flex',
flexDirection: 'column',
minHeight: 0
}}>
<WorksheetStats worksheets={worksheets} />
</div>
)}
</div>
</div>
{/* Gesamtarbeitszeit und Worksheet-Liste - 100% Breite unter dem Bento Box */}
<div style={{
marginTop: '20px',
width: '100%'
}}>
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
/>
</div>
<hr />
<h5 className="mt-4 mb-3">Worksheets (Arbeitsschritte)</h5>
{/* Statistiken */}
{worksheets.length > 0 && (
<>
<WorksheetStats worksheets={worksheets} />
<hr />
</>
)}
{/* Worksheet-Liste */}
<WorksheetList
worksheets={worksheets}
totalTime={getTotalTime()}
loading={worksheetsLoading}
/>
</div>
</div>
</td>
@@ -256,8 +340,15 @@ export default function TicketRow({ ticket, onUpdate, onExpand }) {
workorder={ticket}
onCreate={handleCreateWorksheet}
/>
<StatusHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
worksheets={worksheets}
ticket={ticket}
/>
<tr className="spacer">
<td colSpan={10} style={{ height: '8px', background: '#fff' }}></td>
<td colSpan={10} style={{ height: '12px', background: 'transparent', border: 'none' }}></td>
</tr>
</>
)

View File

@@ -1,6 +1,8 @@
import { FaClock, FaUser, FaExchangeAlt, FaComment } from 'react-icons/fa'
import { useState } from 'react'
import { FaClock, FaUser, FaExchangeAlt, FaComment, FaChevronDown, FaChevronUp } from 'react-icons/fa'
export default function WorksheetList({ worksheets, totalTime, loading }) {
const [expandedWorksheets, setExpandedWorksheets] = useState({})
if (loading) {
return (
<div className="text-center p-4">
@@ -34,141 +36,180 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
return `${date} ${hours}:${mins}`
}
const toggleWorksheet = (wsid) => {
setExpandedWorksheets(prev => ({
...prev,
[wsid]: !prev[wsid]
}))
}
return (
<div className="worksheet-list">
{/* Gesamtzeit-Header */}
<div className="mb-4 p-4 rounded-3 shadow-sm" style={{
background: 'linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%)',
border: 'none'
}}>
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center">
<FaClock className="me-3" size={24} style={{ color: '#059669' }} />
<div>
<strong className="fs-5 d-block" style={{ color: '#064e3b' }}>Gesamtarbeitszeit</strong>
<span className="fs-3 fw-bold" style={{ color: '#059669' }}>{formatTime(totalTime)}</span>
</div>
</div>
<div className="text-end">
<span className="badge px-3 py-2" style={{
background: 'rgba(5, 150, 105, 0.2)',
color: '#059669',
fontSize: '1rem'
}}>
{worksheets.filter(ws => !ws.isComment).length} Worksheet(s)
</span>
</div>
</div>
</div>
{/* Worksheet-Einträge */}
<div className="timeline">
{worksheets.map((ws, index) => (
<div key={ws.$id} className="timeline-item mb-4" style={{
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
}}>
<div className="card border-0 shadow-sm overflow-hidden" style={{
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
}} onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
{worksheets.map((ws, index) => {
const isExpanded = expandedWorksheets[ws.wsid] || false
return (
<div key={ws.$id} className="timeline-item mb-4" style={{
animation: `fadeIn 0.5s ease-in-out ${index * 0.1}s backwards`
}}>
<div className="card-header d-flex justify-content-between align-items-center py-3" style={{
background: ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
border: 'none'
<div className="card border-0 shadow-sm overflow-hidden" style={{
borderLeft: ws.isComment ? '4px solid #10b981' : '4px solid #4a5568',
borderRadius: '8px',
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
}} onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)'
e.currentTarget.style.boxShadow = '0 8px 16px rgba(0,0,0,0.1)'
}} onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)'
e.currentTarget.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)'
}}>
<div>
<strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && (
<span className="badge ms-2" style={{
background: 'rgba(255,255,255,0.3)'
}}>
<FaComment className="me-1" /> Kommentar
</span>
)}
</div>
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime)}
</small>
</div>
<div className="card-body p-4">
{/* Mitarbeiter & Zeit */}
<div className="row mb-3">
<div className="col-md-6">
<div className="d-flex align-items-center">
<FaUser className="me-2" style={{ color: '#10b981' }} />
<strong>{ws.employeeName}</strong>
{ws.employeeShort && (
{/* Header - Immer sichtbar, klickbar */}
<div
className="card-header d-flex justify-content-between align-items-center py-3"
onClick={() => toggleWorksheet(ws.wsid)}
style={{
background: ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
border: 'none',
cursor: 'pointer',
userSelect: 'none',
transition: 'background 0.2s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = ws.isComment
? 'linear-gradient(135deg, #059669 0%, #047857 100%)'
: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = ws.isComment
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)'
}}
>
<div className="d-flex align-items-center gap-3">
{isExpanded ? (
<FaChevronUp style={{ fontSize: '0.9rem', opacity: 0.8 }} />
) : (
<FaChevronDown style={{ fontSize: '0.9rem', opacity: 0.8 }} />
)}
<div>
<strong className="fs-6">WSID {ws.wsid}</strong>
{ws.isComment && (
<span className="badge ms-2" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white'
}}>{ws.employeeShort}</span>
background: 'rgba(255,255,255,0.3)'
}}>
<FaComment className="me-1" /> Kommentar
</span>
)}
</div>
</div>
<div className="col-md-6 text-md-end">
{!ws.isComment && (
<div className="d-flex align-items-center justify-content-md-end">
<FaClock className="me-2" style={{ color: '#10b981' }} />
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
{/* Collapsed: Mitarbeiter & Zeit im Header */}
{!isExpanded && (
<div className="d-flex align-items-center gap-3 ms-3">
<div className="d-flex align-items-center">
<FaUser style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
<span style={{ fontSize: '0.9rem' }}>{ws.employeeName}</span>
</div>
{!ws.isComment && (
<div className="d-flex align-items-center">
<FaClock style={{ fontSize: '0.9rem', marginRight: '0.5rem' }} />
<span style={{ fontSize: '0.9rem' }}>{formatTime(ws.totalTime)}</span>
</div>
)}
<span className="badge" style={{
background: 'rgba(255,255,255,0.2)',
fontSize: '0.8rem'
}}>{ws.serviceType}</span>
</div>
)}
</div>
</div>
{/* Service Type */}
<div className="mb-3">
<span className="badge px-3 py-2" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
fontSize: '0.85rem'
}}>{ws.serviceType}</span>
</div>
{/* Status-Änderung */}
{ws.oldStatus !== ws.newStatus && (
<div className="mb-3 p-3 rounded-3" style={{
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
}}>
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
<span className="text-muted">Status:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
<span className="mx-2" style={{ color: '#10b981' }}></span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
</div>
)}
{/* Response Level-Änderung */}
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
<div className="mb-3">
<span className="text-muted">Response Level:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
<span className="mx-2"></span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
</div>
)}
{/* Details */}
<div className="mt-3 p-3 rounded-3" style={{
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.1)'
}}>
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
{ws.details}
<small style={{ opacity: 0.9 }}>
{formatDateTime(ws.startDate, ws.startTime)}
</small>
</div>
{/* Body - Nur wenn expanded */}
{isExpanded && (
<div
className="card-body p-4"
style={{
animation: 'slideDown 0.3s ease-out'
}}
>
{/* Mitarbeiter & Zeit */}
<div className="row mb-3">
<div className="col-md-6">
<div className="d-flex align-items-center">
<FaUser className="me-2" style={{ color: '#10b981' }} />
<strong>{ws.employeeName}</strong>
{ws.employeeShort && (
<span className="badge ms-2" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
color: 'white'
}}>{ws.employeeShort}</span>
)}
</div>
</div>
<div className="col-md-6 text-md-end">
{!ws.isComment && (
<div className="d-flex align-items-center justify-content-md-end">
<FaClock className="me-2" style={{ color: '#10b981' }} />
<strong className="fs-5" style={{ color: '#10b981' }}>{formatTime(ws.totalTime)}</strong>
</div>
)}
</div>
</div>
{/* Service Type */}
<div className="mb-3">
<span className="badge px-3 py-2" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white',
fontSize: '0.85rem'
}}>{ws.serviceType}</span>
</div>
{/* Status-Änderung */}
{ws.oldStatus !== ws.newStatus && (
<div className="mb-3 p-3 rounded-3" style={{
background: 'linear-gradient(135deg, rgba(16, 185, 129, 0.1) 0%, rgba(5, 150, 105, 0.1) 100%)'
}}>
<FaExchangeAlt className="me-2" style={{ color: '#10b981' }} />
<span className="text-muted">Status:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldStatus}</span>
<span className="mx-2" style={{ color: '#10b981' }}></span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newStatus}</span>
</div>
)}
{/* Response Level-Änderung */}
{ws.oldResponseLevel && ws.newResponseLevel && ws.oldResponseLevel !== ws.newResponseLevel && (
<div className="mb-3">
<span className="text-muted">Response Level:</span>{' '}
<span className="badge" style={{ background: '#6b7280', color: 'white' }}>{ws.oldResponseLevel}</span>
<span className="mx-2"></span>
<span className="badge" style={{ background: '#10b981', color: 'white' }}>{ws.newResponseLevel}</span>
</div>
)}
{/* Details */}
<div className="mt-3 p-3 rounded-3" style={{
background: 'rgba(16, 185, 129, 0.05)',
border: '1px solid rgba(16, 185, 129, 0.1)'
}}>
<small className="text-dark" style={{ whiteSpace: 'pre-wrap', lineHeight: '1.6' }}>
{ws.details}
</small>
</div>
</div>
)}
</div>
</div>
</div>
))}
)
})}
</div>
<style>{`
@@ -182,6 +223,18 @@ export default function WorksheetList({ worksheets, totalTime, loading }) {
transform: translateY(0);
}
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
max-height: 1000px;
transform: translateY(0);
}
}
`}</style>
</div>
)

View File

@@ -1,4 +1,4 @@
import { FaClock, FaUsers, FaHistory, FaChartLine } from 'react-icons/fa'
import { FaClock, FaUsers, FaChartLine } from 'react-icons/fa'
export default function WorksheetStats({ worksheets }) {
if (!worksheets || worksheets.length === 0) {
@@ -28,16 +28,6 @@ export default function WorksheetStats({ worksheets }) {
return acc
}, {})
// Status-Historie
const statusHistory = worksheets
.filter(ws => ws.oldStatus && ws.newStatus && ws.oldStatus !== ws.newStatus)
.map(ws => ({
date: ws.startDate,
time: ws.startTime,
from: ws.oldStatus,
to: ws.newStatus,
employee: ws.employeeName
}))
// Service Type Verteilung
const byServiceType = worksheets.reduce((acc, ws) => {
@@ -53,159 +43,269 @@ export default function WorksheetStats({ worksheets }) {
return hours > 0 ? `${hours}h ${mins}min` : `${mins}min`
}
const formatTimeShort = (time) => {
if (!time || time.length !== 4) return '-'
return `${time.substring(0, 2)}:${time.substring(2, 4)}`
}
return (
<div className="worksheet-stats mb-4">
<div className="row g-4">
{/* Gesamtübersicht */}
<div className="col-lg-4 col-md-6">
<div className="card h-100 border-0 shadow-sm" style={{
background: 'linear-gradient(135deg, #2d3748 0%, #1a202c 100%)',
color: 'white'
<div className="worksheet-stats" style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
height: '100%'
}}>
{/* Gesamtübersicht */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '0 0 auto'
}}>
<h6 style={{
color: 'var(--dark-text)',
marginBottom: '12px',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<FaChartLine size={16} style={{ color: '#10b981' }} />
Gesamtübersicht
</h6>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<div className="card-body p-4">
<h6 className="card-title mb-3 d-flex align-items-center">
<FaChartLine className="me-2" size={20} style={{ color: '#4ade80' }} />
<strong>Gesamtübersicht</strong>
</h6>
<div className="mt-3">
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
<span style={{ opacity: 0.9 }}>Worksheets:</span>
<strong className="fs-5">{worksheets.length}</strong>
</div>
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
<span style={{ opacity: 0.9 }}>Arbeitszeit:</span>
<strong className="fs-5">{formatTime(totalMinutes)}</strong>
</div>
<div className="d-flex justify-content-between align-items-center mb-3 pb-2" style={{ borderBottom: '1px solid rgba(255,255,255,0.2)' }}>
<span style={{ opacity: 0.9 }}>Kommentare:</span>
<strong className="fs-5">{worksheets.filter(ws => ws.isComment).length}</strong>
</div>
<div className="d-flex justify-content-between align-items-center">
<span style={{ opacity: 0.9 }}>Ø pro Worksheet:</span>
<strong className="fs-5">
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
</strong>
</div>
</div>
</div>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Worksheets:</span>
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.length}</strong>
</div>
</div>
{/* Nach Mitarbeiter */}
<div className="col-lg-4 col-md-6">
<div className="card h-100 border-0 shadow-sm" style={{
background: 'linear-gradient(135deg, #22c55e 0%, #10b981 100%)',
color: 'white'
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<div className="card-body p-4">
<h6 className="card-title mb-3 d-flex align-items-center">
<FaUsers className="me-2" size={20} />
<strong>Nach Mitarbeiter</strong>
</h6>
<div className="mt-3">
{Object.values(byEmployee).map((emp, idx) => (
<div key={idx} className="mb-3 pb-3" style={{ borderBottom: idx < Object.values(byEmployee).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
<div className="d-flex justify-content-between align-items-center">
<div>
<strong className="d-block">{emp.name}</strong>
{emp.short && (
<span className="badge mt-1" style={{
background: 'rgba(255,255,255,0.25)'
}}>{emp.short}</span>
)}
</div>
<div className="text-end">
<div className="fs-5 fw-bold">{formatTime(emp.time)}</div>
<small style={{ opacity: 0.8 }}>{emp.count} WS</small>
</div>
</div>
</div>
))}
</div>
</div>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Arbeitszeit:</span>
<strong style={{ color: '#10b981', fontSize: '16px' }}>{formatTime(totalMinutes)}</strong>
</div>
</div>
{/* Service Type Verteilung */}
<div className="col-lg-4 col-md-6">
<div className="card h-100 border-0 shadow-sm" style={{
background: 'linear-gradient(135deg, #4a5568 0%, #2d3748 100%)',
color: 'white'
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<div className="card-body p-4">
<h6 className="card-title mb-3 d-flex align-items-center">
<FaClock className="me-2" size={20} style={{ color: '#4ade80' }} />
<strong>Service Types</strong>
</h6>
<div className="mt-3">
{Object.entries(byServiceType).map(([type, count], idx) => (
<div key={type} className="d-flex justify-content-between align-items-center mb-3 pb-3" style={{ borderBottom: idx < Object.entries(byServiceType).length - 1 ? '1px solid rgba(255,255,255,0.2)' : 'none' }}>
<span className="badge px-3 py-2" style={{
background: 'rgba(255,255,255,0.25)',
fontSize: '0.9rem'
}}>{type}</span>
<strong className="fs-5">{count}</strong>
</div>
))}
</div>
</div>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Kommentare:</span>
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>{worksheets.filter(ws => ws.isComment).length}</strong>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{ color: 'rgba(226, 232, 240, 0.8)', fontSize: '12px' }}>Ø pro WS:</span>
<strong style={{ color: 'var(--dark-text)', fontSize: '16px' }}>
{formatTime(Math.round(totalMinutes / (worksheets.filter(ws => !ws.isComment).length || 1)))}
</strong>
</div>
</div>
</div>
{/* Status-Historie */}
{statusHistory.length > 0 && (
<div className="card border-0 shadow-sm mt-3" style={{
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
{/* Mitarbeiter Kombiniertes Diagramm */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '1 1 auto',
minHeight: '200px'
}}>
<h6 style={{
color: 'var(--dark-text)',
marginBottom: '16px',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<div className="card-body p-4">
<h6 className="card-title text-white mb-3 d-flex align-items-center">
<FaHistory className="me-2" size={20} />
<strong>Status-Historie</strong>
</h6>
<div className="table-responsive mt-3">
<table className="table table-sm" style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
<thead>
<tr style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Datum</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Zeit</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Von</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}></th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Nach</th>
<th style={{ borderColor: 'rgba(255,255,255,0.2)' }}>Mitarbeiter</th>
</tr>
</thead>
<tbody>
{statusHistory.reverse().map((change, idx) => (
<tr key={idx} style={{ color: 'white', borderColor: 'rgba(255,255,255,0.2)' }}>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.date}</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{formatTimeShort(change.time)}</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
<span className="badge" style={{
background: 'rgba(255,255,255,0.25)'
}}>{change.from}</span>
</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}></td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>
<span className="badge" style={{
background: 'rgba(255,255,255,0.4)',
fontWeight: 'bold'
}}>{change.to}</span>
</td>
<td style={{ borderColor: 'rgba(255,255,255,0.2)' }}>{change.employee}</td>
</tr>
))}
</tbody>
</table>
</div>
<FaUsers size={16} style={{ color: '#10b981' }} />
Mitarbeiter-Statistiken
</h6>
{Object.keys(byEmployee).length === 0 ? (
<div style={{ color: '#a0aec0', textAlign: 'center', padding: '20px' }}>
Keine Mitarbeiter-Daten verfügbar
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{(() => {
const employeeArray = Object.values(byEmployee).sort((a, b) => b.time - a.time)
const maxTime = Math.max(...employeeArray.map(e => e.time), 1)
return employeeArray.map((emp, idx) => {
const percentage = maxTime > 0 ? (emp.time / maxTime) * 100 : 0
return (
<div key={idx}>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '6px',
fontSize: '11px'
}}>
<span style={{
color: 'var(--dark-text)',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '6px',
minWidth: '120px'
}}>
{emp.short && (
<span style={{
background: 'rgba(16, 185, 129, 0.2)',
color: '#10b981',
padding: '2px 6px',
borderRadius: '3px',
fontSize: '9px',
fontWeight: 'bold'
}}>{emp.short}</span>
)}
<span style={{ maxWidth: '100px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{emp.name}
</span>
</span>
</div>
<div style={{
width: '100%',
height: '28px',
background: 'rgba(26, 32, 44, 0.6)',
borderRadius: '6px',
overflow: 'hidden',
position: 'relative',
display: 'flex',
alignItems: 'center'
}}>
{/* WS Anzahl am Anfang */}
<div style={{
position: 'absolute',
left: '8px',
zIndex: 2,
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<span>WS</span>
<span style={{
background: 'rgba(255, 255, 255, 0.3)',
padding: '2px 6px',
borderRadius: '4px'
}}>{emp.count}</span>
</div>
{/* Balken mit Zeit */}
<div style={{
width: `${percentage}%`,
height: '100%',
background: 'linear-gradient(90deg, #10b981 0%, #059669 100%)',
borderRadius: '6px',
transition: 'width 0.5s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
paddingRight: '8px',
paddingLeft: '60px',
boxShadow: '0 2px 8px rgba(16, 185, 129, 0.3)',
position: 'relative'
}}>
{/* Zeit am Ende des Balkens */}
<span style={{
color: 'white',
fontSize: '11px',
fontWeight: 'bold',
whiteSpace: 'nowrap'
}}>
{formatTime(emp.time)}
</span>
</div>
{/* Zeit außerhalb des Balkens (falls Balken zu kurz) */}
{percentage < 30 && (
<div style={{
position: 'absolute',
right: '8px',
zIndex: 2,
color: '#10b981',
fontSize: '11px',
fontWeight: 'bold'
}}>
{formatTime(emp.time)}
</div>
)}
</div>
</div>
)
})
})()}
</div>
)}
</div>
{/* Service Type Verteilung */}
<div style={{
background: 'rgba(45, 55, 72, 0.5)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(16, 185, 129, 0.2)',
flex: '0 0 auto'
}}>
<h6 style={{
color: 'var(--dark-text)',
marginBottom: '12px',
fontSize: '14px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}>
<FaClock size={16} style={{ color: '#10b981' }} />
Service Types
</h6>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{Object.entries(byServiceType).map(([type, count]) => (
<div key={type} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px',
background: 'rgba(26, 32, 44, 0.4)',
borderRadius: '6px'
}}>
<span style={{
color: 'var(--dark-text)',
fontSize: '12px',
fontWeight: '500'
}}>{type}</span>
<strong style={{
color: '#10b981',
fontSize: '14px'
}}>{count}</strong>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -137,34 +137,6 @@ export function AuthProvider({ children }) {
}
}
async function register(email, password, name) {
if (DEMO_MODE) {
return login(email, password)
}
try {
// Appwrite SDK 13.0 verwendet ID.unique() für die User ID
await account.create(ID.unique(), email, password, name)
// Login ruft automatisch ensureEmployeeExists auf
await login(email, password)
return { success: true }
} catch (error) {
console.error('Register error:', error)
let errorMessage = error.message || 'Registrierung fehlgeschlagen'
// Bessere Fehlermeldungen
if (errorMessage.includes('already exists') || errorMessage.includes('duplicate')) {
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
} else if (errorMessage.includes('Email/Password')) {
errorMessage = 'Email/Password Authentifizierung ist nicht aktiviert. Bitte aktiviere sie in deinem Appwrite Dashboard unter Auth → Providers.'
} else if (errorMessage.includes('password') && errorMessage.includes('length')) {
errorMessage = 'Das Passwort muss mindestens 8 Zeichen lang sein.'
}
return { success: false, error: errorMessage }
}
}
// Hilfsfunktion um zu prüfen ob Benutzer Admin ist
const isAdmin = () => {
if (!user) return false
@@ -177,7 +149,6 @@ export function AuthProvider({ children }) {
loading,
login,
logout,
register,
isAdmin: isAdmin()
}

View File

@@ -4,8 +4,61 @@ import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo data for testing without Appwrite
const lastWeek = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
const DEMO_WORKORDERS = [
{ $id: '1', woid: '10001', title: 'Server Wartung', description: 'Monatliche Wartung', status: 'Open', priority: 2, type: 'Maintenance', customerName: 'Kunde A', assignedName: 'Max M.', response: 'Onsite', $createdAt: new Date().toISOString() },
{
$id: 'dummy-10001',
woid: '10001',
topic: 'Kompletter Systemausfall - Server & Netzwerk',
status: 'Assigned',
priority: 4,
type: 'Emergency Call',
systemType: 'Server',
responseLevel: 'Backoffice',
serviceType: 'On Site',
customerName: 'Kunde A',
customerLocation: 'Hauptstraße 123, 12345 Musterstadt',
assignedTo: 'user-max-id',
assignedName: 'Max Mustermann',
requestedBy: 'Dr. Anna Schmidt',
requestedFor: 'IT-Abteilung Kunde A',
startDate: '30.12.2025',
startTime: '0800',
deadline: '31.12.2025',
endTime: '1800',
estimate: '480',
mailCopyTo: 'admin@kunde-a.de, it@kunde-a.de',
sendNotification: true,
details: `KRITISCHER SYSTEMAUSFALL - SOFORTIGE BEARBEITUNG ERFORDERLICH
Problembeschreibung:
- Kompletter Serverausfall im Rechenzentrum
- Alle Server sind offline (keine Verbindung möglich)
- Netzwerk-Infrastruktur betroffen
- Keine Backup-Systeme verfügbar
Betroffene Systeme:
- Hauptserver (Windows Server 2022)
- Datenbankserver (SQL Server 2019)
- Fileserver
- Exchange Server
- Netzwerk-Switches
Auswirkungen:
- Keine E-Mail-Kommunikation möglich
- Alle Anwendungen offline
- Kein Zugriff auf Datenbanken
- Produktion steht still
Dringlichkeit: KRITISCH - Produktionsausfall
Erwartete Bearbeitungszeit: 8 Stunden
Benötigte Ressourcen: 2 Techniker, Hardware-Ersatzteile`,
approvalStatus: 'approved',
$createdAt: lastWeek.toISOString(),
createdAt: lastWeek.toISOString()
},
{ $id: '2', woid: '10002', title: 'Netzwerk Problem', description: 'WLAN funktioniert nicht', status: 'Occupied', priority: 3, type: 'Support', customerName: 'Kunde B', assignedName: 'Lisa S.', response: 'Remote', $createdAt: new Date().toISOString() },
{ $id: '3', woid: '10003', title: 'Software Installation', description: 'Office 365 Setup', status: 'Assigned', priority: 1, type: 'Installation', customerName: 'Kunde C', assignedName: 'Tom K.', response: 'Onsite', $createdAt: new Date().toISOString() },
{ $id: '4', woid: '10004', title: 'Drucker defekt', description: 'Papierstau', status: 'Awaiting', priority: 2, type: 'Hardware', customerName: 'Kunde D', assignedName: '', response: 'Pickup', $createdAt: new Date().toISOString() },

View File

@@ -3,28 +3,144 @@ import { databases, DATABASE_ID, COLLECTIONS, Query, ID } from '../lib/appwrite'
const DEMO_MODE = !import.meta.env.VITE_APPWRITE_PROJECT_ID
// Demo data für Testing
// Demo data für Testing - Vollständiges Dummy-Ticket 10001 mit allen Worksheets
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
const twoDaysAgo = new Date(Date.now() - 48 * 60 * 60 * 1000)
const threeDaysAgo = new Date(Date.now() - 72 * 60 * 60 * 1000)
const DEMO_WORKSHEETS = [
{
$id: '1',
$id: 'ws-10001-001',
wsid: '100001',
woid: '10001',
workorderId: '1',
employeeId: 'emp1',
employeeName: 'Max Müller',
employeeShort: 'MAMU',
workorderId: 'dummy-10001',
employeeId: 'user-max-id',
employeeName: 'Max Mustermann',
employeeShort: 'MM',
serviceType: 'Remote',
oldStatus: 'Open',
newStatus: 'Occupied',
oldResponseLevel: '',
newResponseLevel: '24/7',
totalTime: 30,
startDate: '29.12.2025',
startTime: '1000',
endDate: '29.12.2025',
endTime: '1030',
details: 'Router neu gestartet',
startDate: '23.12.2025',
startTime: '0800',
endDate: '23.12.2025',
endTime: '0830',
details: 'Erste Analyse durchgeführt. Server komplett offline. Keine Remote-Verbindung möglich. Vor-Ort-Einsatz erforderlich.',
isComment: false,
$createdAt: new Date().toISOString()
$createdAt: threeDaysAgo.toISOString()
},
{
$id: 'ws-10001-002',
wsid: '100002',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-lisa-id',
employeeName: 'Lisa Schneider',
employeeShort: 'LS',
serviceType: 'On Site',
oldStatus: 'Occupied',
newStatus: 'Assigned',
oldResponseLevel: '24/7',
newResponseLevel: '24/7',
totalTime: 120,
startDate: '23.12.2025',
startTime: '1000',
endDate: '23.12.2025',
endTime: '1200',
details: 'Vor-Ort-Einsatz: Hardware-Check durchgeführt. Netzteil des Hauptservers defekt. Ersatzteil bestellt. Notfall-Backup-Server gestartet.',
isComment: false,
$createdAt: threeDaysAgo.toISOString()
},
{
$id: 'ws-10001-003',
wsid: '100003',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-tom-id',
employeeName: 'Tom Klein',
employeeShort: 'TK',
serviceType: 'On Site',
oldStatus: 'Assigned',
newStatus: 'Assigned',
oldResponseLevel: '24/7',
newResponseLevel: '24/7',
totalTime: 0,
startDate: '24.12.2025',
startTime: '1400',
endDate: '24.12.2025',
endTime: '1400',
details: 'Warte auf Ersatzteil-Lieferung. Kunde informiert. Backup-System läuft stabil.',
isComment: true,
$createdAt: twoDaysAgo.toISOString()
},
{
$id: 'ws-10001-004',
wsid: '100004',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-max-id',
employeeName: 'Max Mustermann',
employeeShort: 'MM',
serviceType: 'On Site',
oldStatus: 'Assigned',
newStatus: 'In Test',
oldResponseLevel: '24/7',
newResponseLevel: '24/7',
totalTime: 180,
startDate: '25.12.2025',
startTime: '0900',
endDate: '25.12.2025',
endTime: '1200',
details: 'Ersatzteil eingebaut. Server gestartet. Alle Dienste wiederhergestellt. System-Tests durchgeführt. Datenbank-Verbindungen geprüft.',
isComment: false,
$createdAt: twoDaysAgo.toISOString()
},
{
$id: 'ws-10001-005',
wsid: '100005',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-lisa-id',
employeeName: 'Lisa Schneider',
employeeShort: 'LS',
serviceType: 'Remote',
oldStatus: 'In Test',
newStatus: 'Awaiting',
oldResponseLevel: '24/7',
newResponseLevel: 'Support',
totalTime: 45,
startDate: '26.12.2025',
startTime: '1000',
endDate: '26.12.2025',
endTime: '1045',
details: 'Remote-Monitoring eingerichtet. Warte auf Kunden-Feedback nach 24h Testphase. Alle Systeme laufen stabil.',
isComment: false,
$createdAt: yesterday.toISOString()
},
{
$id: 'ws-10001-006',
wsid: '100006',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-tom-id',
employeeName: 'Tom Klein',
employeeShort: 'TK',
serviceType: 'COMMENT',
oldStatus: 'Awaiting',
newStatus: 'Closed',
oldResponseLevel: 'Support',
newResponseLevel: 'Backoffice',
totalTime: 0,
startDate: '30.12.2025',
startTime: '0900',
endDate: '30.12.2025',
endTime: '0900',
details: 'Kunde bestätigt: Alle Systeme funktionieren einwandfrei. Problem vollständig behoben. Ticket kann geschlossen werden.',
isComment: true,
$createdAt: new Date().toISOString()
}
]
export function useWorksheets(woid = null) {

View File

@@ -1,16 +1,15 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import LetterGlitch from '../components/LetterGlitch'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [isRegistering, setIsRegistering] = useState(false)
const { login, register } = useAuth()
const { login } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
@@ -19,9 +18,7 @@ export default function LoginPage() {
setLoading(true)
try {
const result = isRegistering
? await register(email, password, name || email.split('@')[0])
: await login(email, password)
const result = await login(email, password)
if (result.success) {
navigate('/tickets')
@@ -31,12 +28,6 @@ export default function LoginPage() {
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('401')) {
errorMessage = 'Ungültige Email oder Passwort. Bitte überprüfe deine Eingaben.'
} else if (errorMessage.includes('User already exists')) {
errorMessage = 'Ein Benutzer mit dieser Email existiert bereits. Bitte logge dich ein.'
setIsRegistering(false)
} else if (errorMessage.includes('User with the same email already exists')) {
errorMessage = 'Diese Email ist bereits registriert. Bitte logge dich ein.'
setIsRegistering(false)
} else if (errorMessage.includes('Email/Password') || errorMessage.includes('auth')) {
errorMessage = 'Email/Password Authentifizierung ist möglicherweise nicht aktiviert. Bitte überprüfe deine Appwrite-Konfiguration.'
}
@@ -52,95 +43,100 @@ export default function LoginPage() {
return (
<div style={{
position: 'relative',
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f1f1f1'
width: '100%',
overflow: 'hidden'
}}>
<div className="card" style={{ width: '400px' }}>
<div className="card-header text-center">
<h2>Webklar WOMS 2.0</h2>
</div>
<div className="card-body">
<form onSubmit={handleSubmit}>
{error && (
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
{isRegistering && (
{/* LetterGlitch Hintergrund */}
<div style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
zIndex: 0
}}>
<LetterGlitch
glitchSpeed={50}
centerVignette={true}
outerVignette={false}
smooth={true}
/>
</div>
{/* Login-Formular */}
<div style={{
position: 'relative',
zIndex: 1,
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '400px',
boxShadow: '0 10px 40px rgba(0,0,0,0.5)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.2)',
borderRadius: '8px',
marginBottom: '16px'
}}>
<div style={{
padding: '12px 16px',
background: 'transparent',
color: '#fff',
fontWeight: 'bold',
borderBottom: '1px solid rgba(255, 255, 255, 0.2)',
textAlign: 'center'
}}>
<h2>Webklar WOMS 2.0</h2>
</div>
<div style={{ padding: '16px', color: '#e2e8f0' }}>
<form onSubmit={handleSubmit}>
{error && (
<div className="bg-red text-white p-1 mb-2" style={{ borderRadius: '4px' }}>
{error}
</div>
)}
<div className="form-group">
<label className="form-label">Name (optional)</label>
<label className="form-label">Email</label>
<input
type="text"
type="email"
className="form-control"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Dein Name"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="deine@email.com"
/>
</div>
)}
<div className="form-group">
<label className="form-label">Email</label>
<input
type="email"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="deine@email.com"
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
className="form-control"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
minLength={8}
/>
</div>
<div className="form-group">
<label className="form-label">Password</label>
<input
type="password"
className="form-control"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
minLength={8}
/>
</div>
<button
type="submit"
className="btn btn-green"
style={{ width: '100%', marginBottom: '10px' }}
disabled={loading}
>
{loading
? (isRegistering ? 'Registrierung läuft...' : 'Login läuft...')
: (isRegistering ? 'Registrieren' : 'Login')
}
</button>
<button
type="button"
className="btn"
style={{
width: '100%',
background: 'transparent',
border: '1px solid #ccc',
color: '#333'
}}
onClick={() => {
setIsRegistering(!isRegistering)
setError('')
}}
disabled={loading}
>
{isRegistering
? 'Bereits registriert? Hier einloggen'
: 'Noch kein Account? Hier registrieren'
}
</button>
</form>
<button
type="submit"
className="btn btn-green"
style={{ width: '100%' }}
disabled={loading}
>
{loading ? 'Login läuft...' : 'Login'}
</button>
</form>
</div>
</div>
</div>
</div>

View File

@@ -9,30 +9,50 @@ import QuickOverviewModal from '../components/QuickOverviewModal'
export default function TicketsPage() {
const [limit, setLimit] = useState(10)
// Aktive Filter (werden für API-Calls verwendet)
const [filters, setFilters] = useState({
status: ['Open', 'Occupied', 'Assigned', 'Awaiting', 'Added Info'],
type: [],
priority: [],
limit: 10
})
// Lokale Filter-Eingaben (werden nur beim Apply angewendet)
const [localFilters, setLocalFilters] = useState({
woid: '',
customer: '',
userTopic: '',
createdDate: '',
type: '',
system: '',
priority: ''
})
const { workorders, loading, error, refresh, updateWorkorder, createWorkorder } = useWorkorders(filters)
const { customers } = useCustomers()
const [showCreateModal, setShowCreateModal] = useState(false)
const [showOverviewModal, setShowOverviewModal] = useState(false)
const handleFilterChange = (newFilters) => {
setFilters({ ...newFilters, limit })
}
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const handleApplyFilters = () => {
refresh()
// Wende lokale Filter auf aktive Filter an
setFilters(prev => ({
...prev,
woid: localFilters.woid || undefined,
customer: localFilters.customer || undefined,
userTopic: localFilters.userTopic || undefined,
createdDate: localFilters.createdDate || undefined,
type: localFilters.type ? [localFilters.type] : [],
system: localFilters.system ? [localFilters.system] : [],
priority: localFilters.priority ? [parseInt(localFilters.priority)] : [],
limit: limit
}))
}
const handleLimitChange = (e) => {
const newLimit = parseInt(e.target.value)
setLimit(newLimit)
// Limit-Änderung wird sofort angewendet (kein Apply nötig)
setFilters(prev => ({ ...prev, limit: newLimit }))
}
@@ -55,133 +75,98 @@ export default function TicketsPage() {
return (
<div className="main-content">
{/* Sticky Header Container */}
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.2)',
padding: '24px',
marginBottom: '24px',
textAlign: 'center'
}}>
<h2 style={{
color: 'var(--dark-text)',
marginBottom: '12px',
fontSize: '28px',
fontWeight: 'bold'
}}>
Active Tickets Overview
</h2>
<p style={{ color: '#a0aec0', marginBottom: '8px' }}>
Work Order loading limit is set to <span style={{
fontSize: '24px',
color: 'var(--green-primary)',
fontWeight: 'bold'
}}>{limit}</span>.
Reduce value to increase reload speed.
</p>
<p style={{ color: '#718096', fontSize: '12px' }}>
Last page reload: {format(new Date(), 'dd.MM.yyyy, HH:mm:ss')}
</p>
</div>
{/* Unified Control Panel - All in One */}
<div style={{
background: 'rgba(45, 55, 72, 0.95)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.2)',
overflow: 'hidden',
position: 'sticky',
top: 0,
zIndex: 100,
marginBottom: '24px'
}}>
{/* Extended Filters + Quick Selection - TOP */}
<div style={{
padding: '20px',
borderBottom: '1px solid rgba(16, 185, 129, 0.2)'
{/* Compact Control Panel */}
<div style={{
background: 'rgba(26, 32, 44, 0.4)',
backdropFilter: 'blur(25px) saturate(180%)',
WebkitBackdropFilter: 'blur(25px) saturate(180%)',
borderRadius: '12px',
border: '1px solid rgba(16, 185, 129, 0.3)',
overflow: 'hidden',
padding: '16px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
marginBottom: '16px'
}}>
{/* Main Filter Row */}
{/* Title Row */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '16px',
flexWrap: 'wrap',
gap: '12px'
}}>
<h2 style={{
color: 'var(--dark-text)',
fontSize: '24px',
fontWeight: 'bold',
margin: 0
}}>
Tickets
</h2>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-dark"
onClick={() => setShowCreateModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
CREATE NEW TICKET
</button>
<button
className="btn btn-dark"
onClick={() => setShowOverviewModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
QUICK OVERVIEW
</button>
<button
className="btn btn-green"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
style={{ whiteSpace: 'nowrap' }}
>
{showAdvancedFilters ? '▲ Hide Filters' : '▼ Show Filters'}
</button>
</div>
</div>
{/* Main Search Bar */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center',
marginBottom: '16px'
alignItems: 'center'
}}>
<input
type="text"
placeholder="WOID"
className="form-control"
style={{ margin: 0 }}
value={filters.woid || ''}
onChange={(e) => setFilters({ ...filters, woid: e.target.value })}
value={localFilters.woid || ''}
onChange={(e) => setLocalFilters({ ...localFilters, woid: e.target.value })}
/>
<input
type="text"
placeholder="Created Date"
className="form-control"
style={{ margin: 0 }}
value={filters.createdDate || ''}
onChange={(e) => setFilters({ ...filters, createdDate: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={filters.type?.[0] || ''}
onChange={(e) => setFilters({ ...filters, type: e.target.value ? [e.target.value] : [] })}
>
<option value="">Type / Location</option>
<option>Home Office</option>
<option>Holidays</option>
<option>Trip</option>
<option>Supportrequest</option>
<option>Change Request</option>
<option>Maintenance</option>
<option>Project</option>
<option>Procurement</option>
<option>Emergency Call</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={filters.system?.[0] || ''}
onChange={(e) => setFilters({ ...filters, system: e.target.value ? [e.target.value] : [] })}
>
<option value="">System</option>
<option>Client</option>
<option>Server</option>
<option>Network</option>
<option>EDI</option>
<option>TOS</option>
<option>Reports</option>
<option>n/a</option>
</select>
<input
type="text"
placeholder="Customer"
className="form-control"
style={{ margin: 0 }}
value={filters.customer || ''}
onChange={(e) => setFilters({ ...filters, customer: e.target.value })}
value={localFilters.customer || ''}
onChange={(e) => setLocalFilters({ ...localFilters, customer: e.target.value })}
/>
<input
type="text"
placeholder="Topic / User"
placeholder="User"
className="form-control"
style={{ margin: 0 }}
value={filters.userTopic || ''}
onChange={(e) => setFilters({ ...filters, userTopic: e.target.value })}
value={localFilters.userTopic || ''}
onChange={(e) => setLocalFilters({ ...localFilters, userTopic: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={filters.priority?.[0] ?? ''}
onChange={(e) => setFilters({ ...filters, priority: e.target.value ? [parseInt(e.target.value)] : [] })}
>
<option value="">Priority</option>
<option value="0">None</option>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
<option value="4">Critical</option>
</select>
<button
className="btn btn-green"
onClick={handleApplyFilters}
@@ -191,89 +176,164 @@ export default function TicketsPage() {
</button>
</div>
{/* Quick Selection Buttons */}
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
paddingTop: '16px',
borderTop: '1px solid rgba(16, 185, 129, 0.1)'
}}>
<button
className="btn btn-green"
onClick={() => { setFilters(prev => ({ ...prev, type: ['Procurement'] })); handleApplyFilters(); }}
>
Procurements
</button>
<button
className="btn btn-green"
onClick={() => { setFilters(prev => ({ ...prev, priority: [4] })); handleApplyFilters(); }}
>
Criticals
</button>
<button
className="btn btn-green"
onClick={() => { setFilters(prev => ({ ...prev, priority: [3] })); handleApplyFilters(); }}
>
Highs
</button>
{/* Advanced Filters - Collapsible */}
{showAdvancedFilters && (
<div style={{
width: '1px',
height: '32px',
background: 'rgba(16, 185, 129, 0.3)',
margin: '0 8px'
}}></div>
<button
className="btn btn-green"
onClick={() => { setLimit(10); setFilters(prev => ({ ...prev, limit: 10 })) }}
>
10
</button>
<button
className="btn btn-green"
onClick={() => { setLimit(25); setFilters(prev => ({ ...prev, limit: 25 })) }}
>
25
</button>
</div>
</div>
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid rgba(16, 185, 129, 0.2)'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
gap: '12px',
alignItems: 'center',
marginBottom: '16px'
}}>
<input
type="text"
placeholder="Created Date"
className="form-control"
style={{ margin: 0 }}
value={localFilters.createdDate || ''}
onChange={(e) => setLocalFilters({ ...localFilters, createdDate: e.target.value })}
/>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.type || ''}
onChange={(e) => setLocalFilters({ ...localFilters, type: e.target.value })}
>
<option value="">Type / Location</option>
<option>Home Office</option>
<option>Holidays</option>
<option>Trip</option>
<option>Supportrequest</option>
<option>Change Request</option>
<option>Maintenance</option>
<option>Project</option>
<option>Procurement</option>
<option>Emergency Call</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.system || ''}
onChange={(e) => setLocalFilters({ ...localFilters, system: e.target.value })}
>
<option value="">System</option>
<option>Client</option>
<option>Server</option>
<option>Network</option>
<option>EDI</option>
<option>TOS</option>
<option>Reports</option>
<option>n/a</option>
</select>
<select
className="form-control"
style={{ margin: 0 }}
value={localFilters.priority || ''}
onChange={(e) => setLocalFilters({ ...localFilters, priority: e.target.value })}
>
<option value="">Priority</option>
<option value="0">None</option>
<option value="1">Low</option>
<option value="2">Medium</option>
<option value="3">High</option>
<option value="4">Critical</option>
</select>
</div>
{/* Slider + Action Buttons - BOTTOM */}
<div style={{
padding: '20px',
display: 'flex',
gap: '16px',
alignItems: 'center',
flexWrap: 'wrap'
}}>
<div style={{ flex: '1', minWidth: '200px' }}>
<input
type="range"
min="5"
max="50"
value={limit}
className="slider"
onChange={handleLimitChange}
/>
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'nowrap' }}>
<button
className="btn btn-dark"
onClick={() => setShowCreateModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
CREATE NEW TICKET
</button>
<button
className="btn btn-dark"
onClick={() => setShowOverviewModal(true)}
style={{ whiteSpace: 'nowrap' }}
>
QUICK OVERVIEW
</button>
</div>
{/* Quick Selection Buttons */}
<div style={{
display: 'flex',
gap: '8px',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
marginBottom: '16px'
}}>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, type: 'Procurement' }))
setFilters(prev => ({ ...prev, type: ['Procurement'] }))
setTimeout(() => refresh(), 0)
}}
>
Procurements
</button>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, priority: '4' }))
setFilters(prev => ({ ...prev, priority: [4] }))
setTimeout(() => refresh(), 0)
}}
>
Criticals
</button>
<button
className="btn btn-green"
onClick={() => {
setLocalFilters(prev => ({ ...prev, priority: '3' }))
setFilters(prev => ({ ...prev, priority: [3] }))
setTimeout(() => refresh(), 0)
}}
>
Highs
</button>
<div style={{
width: '1px',
height: '32px',
background: 'rgba(16, 185, 129, 0.3)',
margin: '0 8px'
}}></div>
<button
className="btn btn-green"
onClick={() => {
setLimit(10)
setFilters(prev => ({ ...prev, limit: 10 }))
setTimeout(() => refresh(), 0)
}}
>
10
</button>
<button
className="btn btn-green"
onClick={() => {
setLimit(25)
setFilters(prev => ({ ...prev, limit: 25 }))
setTimeout(() => refresh(), 0)
}}
>
25
</button>
</div>
{/* Slider */}
<div style={{
display: 'flex',
gap: '16px',
alignItems: 'center'
}}>
<span style={{ color: 'var(--dark-text)', minWidth: '120px' }}>
Load Limit: {limit}
</span>
<div style={{ flex: '1' }}>
<input
type="range"
min="5"
max="50"
value={limit}
className="slider"
onChange={handleLimitChange}
/>
</div>
</div>
</div>
)}
</div>
</div>

View File

@@ -329,6 +329,7 @@ textarea.form-control {
.ticket-row td {
background: rgba(45, 55, 72, 0.95);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 0 !important;
}
/* Second row of ticket (no top border to avoid line in rowspan cells) */
@@ -345,6 +346,60 @@ textarea.form-control {
box-shadow: none;
}
/* Collapsed Ticket - All 4 outermost corners rounded */
/* WOID cell (left side) - top and bottom corners */
.ticket-collapsed .ticket-id {
border-top-left-radius: 12px !important;
border-bottom-left-radius: 12px !important;
}
/* Lock cell (right side) - top and bottom corners */
.ticket-collapsed td.bg-dark-grey {
border-top-right-radius: 12px !important;
border-bottom-right-radius: 12px !important;
}
/* Expanded Ticket - Only top 2 outermost corners rounded */
/* WOID cell (left side) - only top corner, bottom stays square */
.ticket-expanded .ticket-id {
border-top-left-radius: 12px !important;
border-bottom-left-radius: 0 !important;
}
/* Lock cell (right side) - only top corner, bottom stays square */
.ticket-expanded td.bg-dark-grey {
border-top-right-radius: 12px !important;
border-bottom-right-radius: 0 !important;
}
/* Worksheet Expansion with Animation */
.worksheet-expansion {
animation: slideDown 0.4s ease-out;
}
.worksheet-cell {
background: transparent !important;
border: none !important;
padding: 0 !important;
}
.worksheet-cell .card {
border-radius: 0 0 12px 12px !important;
margin-top: -1px;
animation: fadeIn 0.4s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ticket-id {
background: linear-gradient(135deg, #2d3748 0%, #1a202c 100%) !important;
color: #fff;
@@ -354,6 +409,16 @@ textarea.form-control {
font-weight: bold;
padding: 16px;
border: 1px solid rgba(16, 185, 129, 0.2) !important;
transition: border-radius 0.3s ease;
}
/* WOID rounded when collapsed, square bottom when expanded */
.ticket-id:not(.ticket-id-expanded) {
border-radius: 12px;
}
.ticket-id-expanded {
border-radius: 12px 12px 0 0;
}
.ticket-time {
@@ -385,6 +450,21 @@ textarea.form-control {
.dropdown {
position: relative;
display: inline-block;
width: 100%;
text-align: center;
}
.dropdown .btn {
width: 100%;
padding: 6px 12px;
font-size: 12px;
border: none !important;
box-shadow: none !important;
background: inherit !important;
color: inherit !important;
text-align: center;
display: inline-block;
margin: 0;
}
.dropdown-content {
@@ -396,6 +476,8 @@ textarea.form-control {
border: 1px solid rgba(16, 185, 129, 0.3);
border-radius: 6px;
z-index: 100;
left: 50%;
transform: translateX(-50%);
}
.dropdown:hover .dropdown-content {

View File

@@ -0,0 +1,233 @@
/**
* Erstellt ein vollständiges Dummy-Ticket mit WOID 10001
* Zeigt alle möglichen Funktionen, Felder und Kombinationen
*/
export function createDummyTicket10001() {
const now = new Date()
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
const lastWeek = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
// Haupt-Ticket mit allen Feldern ausgefüllt
const dummyTicket = {
$id: 'dummy-10001',
woid: '10001',
topic: 'Kompletter Systemausfall - Server & Netzwerk',
status: 'Assigned',
priority: 4, // Critical
type: 'Emergency Call',
systemType: 'Server',
responseLevel: '24/7',
serviceType: 'On Site',
customerId: 'customer-a-id',
customerName: 'Kunde A',
customerLocation: 'Hauptstraße 123, 12345 Musterstadt',
assignedTo: 'user-max-id', // Max Mustermann
requestedBy: 'Dr. Anna Schmidt',
requestedFor: 'IT-Abteilung Kunde A',
startDate: '30.12.2025',
startTime: '0800',
deadline: '31.12.2025',
endTime: '1800',
estimate: '480',
mailCopyTo: 'admin@kunde-a.de, it@kunde-a.de',
sendNotification: true,
details: `KRITISCHER SYSTEMAUSFALL - SOFORTIGE BEARBEITUNG ERFORDERLICH
Problembeschreibung:
- Kompletter Serverausfall im Rechenzentrum
- Alle Server sind offline (keine Verbindung möglich)
- Netzwerk-Infrastruktur betroffen
- Keine Backup-Systeme verfügbar
Betroffene Systeme:
- Hauptserver (Windows Server 2022)
- Datenbankserver (SQL Server 2019)
- Fileserver
- Exchange Server
- Netzwerk-Switches
Auswirkungen:
- Keine E-Mail-Kommunikation möglich
- Alle Anwendungen offline
- Kein Zugriff auf Datenbanken
- Produktion steht still
Dringlichkeit: KRITISCH - Produktionsausfall
Erwartete Bearbeitungszeit: 8 Stunden
Benötigte Ressourcen: 2 Techniker, Hardware-Ersatzteile`,
approvalStatus: 'approved',
createdAt: lastWeek.toISOString(),
$createdAt: lastWeek.toISOString()
}
// Mehrere Worksheets mit verschiedenen Status-Änderungen und Benutzern
const dummyWorksheets = [
{
$id: 'ws-10001-001',
wsid: '100001',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-max-id',
employeeName: 'Max Mustermann',
employeeShort: 'MM',
serviceType: 'Remote',
oldStatus: 'Open',
newStatus: 'Occupied',
oldResponseLevel: '',
newResponseLevel: '24/7',
totalTime: 30,
startDate: '23.12.2025',
startTime: '0800',
endDate: '23.12.2025',
endTime: '0830',
details: 'Erste Analyse durchgeführt. Server komplett offline. Keine Remote-Verbindung möglich. Vor-Ort-Einsatz erforderlich.',
isComment: false,
createdAt: yesterday.toISOString(),
$createdAt: yesterday.toISOString()
},
{
$id: 'ws-10001-002',
wsid: '100002',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-lisa-id',
employeeName: 'Lisa Schneider',
employeeShort: 'LS',
serviceType: 'On Site',
oldStatus: 'Occupied',
newStatus: 'Assigned',
oldResponseLevel: '24/7',
newResponseLevel: '24/7',
totalTime: 120,
startDate: '23.12.2025',
startTime: '1000',
endDate: '23.12.2025',
endTime: '1200',
details: 'Vor-Ort-Einsatz: Hardware-Check durchgeführt. Netzteil des Hauptservers defekt. Ersatzteil bestellt. Notfall-Backup-Server gestartet.',
isComment: false,
createdAt: yesterday.toISOString(),
$createdAt: yesterday.toISOString()
},
{
$id: 'ws-10001-003',
wsid: '100003',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-tom-id',
employeeName: 'Tom Klein',
employeeShort: 'TK',
serviceType: 'On Site',
oldStatus: 'Assigned',
newStatus: 'Assigned',
oldResponseLevel: '24/7',
newResponseLevel: '24/7',
totalTime: 0,
startDate: '24.12.2025',
startTime: '1400',
endDate: '24.12.2025',
endTime: '1400',
details: 'Warte auf Ersatzteil-Lieferung. Kunde informiert. Backup-System läuft stabil.',
isComment: true,
createdAt: new Date(yesterday.getTime() - 12 * 60 * 60 * 1000).toISOString(),
$createdAt: new Date(yesterday.getTime() - 12 * 60 * 60 * 1000).toISOString()
},
{
$id: 'ws-10001-004',
wsid: '100004',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-max-id',
employeeName: 'Max Mustermann',
employeeShort: 'MM',
serviceType: 'On Site',
oldStatus: 'Assigned',
newStatus: 'In Test',
oldResponseLevel: '24/7',
newResponseLevel: '24/7',
totalTime: 180,
startDate: '25.12.2025',
startTime: '0900',
endDate: '25.12.2025',
endTime: '1200',
details: 'Ersatzteil eingebaut. Server gestartet. Alle Dienste wiederhergestellt. System-Tests durchgeführt. Datenbank-Verbindungen geprüft.',
isComment: false,
createdAt: new Date(yesterday.getTime() - 24 * 60 * 60 * 1000).toISOString(),
$createdAt: new Date(yesterday.getTime() - 24 * 60 * 60 * 1000).toISOString()
},
{
$id: 'ws-10001-005',
wsid: '100005',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-lisa-id',
employeeName: 'Lisa Schneider',
employeeShort: 'LS',
serviceType: 'Remote',
oldStatus: 'In Test',
newStatus: 'Awaiting',
oldResponseLevel: '24/7',
newResponseLevel: 'Support',
totalTime: 45,
startDate: '26.12.2025',
startTime: '1000',
endDate: '26.12.2025',
endTime: '1045',
details: 'Remote-Monitoring eingerichtet. Warte auf Kunden-Feedback nach 24h Testphase. Alle Systeme laufen stabil.',
isComment: false,
createdAt: new Date(yesterday.getTime() - 48 * 60 * 60 * 1000).toISOString(),
$createdAt: new Date(yesterday.getTime() - 48 * 60 * 60 * 1000).toISOString()
},
{
$id: 'ws-10001-006',
wsid: '100006',
woid: '10001',
workorderId: 'dummy-10001',
employeeId: 'user-tom-id',
employeeName: 'Tom Klein',
employeeShort: 'TK',
serviceType: 'COMMENT',
oldStatus: 'Awaiting',
newStatus: 'Closed',
oldResponseLevel: 'Support',
newResponseLevel: 'Backoffice',
totalTime: 0,
startDate: '30.12.2025',
startTime: '0900',
endDate: '30.12.2025',
endTime: '0900',
details: 'Kunde bestätigt: Alle Systeme funktionieren einwandfrei. Problem vollständig behoben. Ticket kann geschlossen werden.',
isComment: true,
createdAt: now.toISOString(),
$createdAt: now.toISOString()
}
]
return {
ticket: dummyTicket,
worksheets: dummyWorksheets
}
}
/**
* Fügt das Dummy-Ticket zu den Demo-Daten hinzu
*/
export function addDummyTicketToDemo(workorders, worksheets) {
const { ticket, worksheets: ticketWorksheets } = createDummyTicket10001()
// Prüfe ob Ticket bereits existiert
const exists = workorders.some(wo => wo.woid === '10001')
if (exists) {
console.log('Dummy-Ticket 10001 existiert bereits')
return { workorders, worksheets }
}
return {
workorders: [ticket, ...workorders],
worksheets: [...ticketWorksheets, ...worksheets]
}
}